feat: user can enable/disable a watchlist

This commit is contained in:
Maël Gangloff 2025-10-25 19:23:15 +02:00
parent 5243b3c2dd
commit 24e3bc19ff
No known key found for this signature in database
GPG Key ID: 11FDC81C24A7F629
13 changed files with 212 additions and 37 deletions

View File

@ -0,0 +1,59 @@
import {Button, Drawer, Form} from 'antd'
import {t} from 'ttag'
import {WatchlistForm} from './WatchlistForm'
import React, {useState} from 'react'
import type {Connector} from '../../../utils/api/connectors'
import useBreakpoint from "../../../hooks/useBreakpoint"
export function CreateWatchlistButton({onUpdateWatchlist, connectors}: {
onUpdateWatchlist: (values: {
domains: string[],
trackedEvents: string[],
trackedEppStatus: string[],
token: string
}) => Promise<void>
connectors: Array<Connector & { id: string }>
}) {
const [form] = Form.useForm()
const [open, setOpen] = useState(false)
const [loading, setLoading] = useState(false)
const sm = useBreakpoint('sm')
const showDrawer = () => setOpen(true)
const onClose = () => {
setOpen(false)
setLoading(false)
}
return (
<>
<Button type='default' block onClick={() => {
showDrawer()
}}>{t`Create a Watchlist`}</Button>
<Drawer
title={t`Create a Watchlist`}
width={sm ? '100%' : '80%'}
onClose={onClose}
open={open}
loading={loading}
styles={{
body: {
paddingBottom: 80
}
}}
extra={<Button onClick={onClose}>{t`Cancel`}</Button>}
>
<WatchlistForm
form={form}
onFinish={values => {
setLoading(true)
onUpdateWatchlist(values).then(onClose).catch(() => setLoading(false))
}}
connectors={connectors}
isCreation
/>
</Drawer>
</>
)
}

View File

@ -0,0 +1,33 @@
import {Popconfirm, theme, Typography} from 'antd'
import {t} from 'ttag'
import type { Watchlist} from '../../../utils/api'
import {patchWatchlist} from '../../../utils/api'
import {PauseCircleOutlined, PlayCircleOutlined} from '@ant-design/icons'
import React from 'react'
export function DisableWatchlistButton({watchlist, onChange, enabled}: {
watchlist: Watchlist,
onChange: () => void,
enabled: boolean
}) {
const {token} = theme.useToken()
return (
enabled ?
<Popconfirm
title={t`Disable the Watchlist`}
description={t`Are you sure to disable this Watchlist?`}
onConfirm={async () => await patchWatchlist(watchlist.token, {enabled: !enabled}).then(onChange)}
okText={t`Yes`}
cancelText={t`No`}
okButtonProps={{danger: true}}
>
<Typography.Link>
<PauseCircleOutlined style={{color: token.colorText}} title={t`Disable the Watchlist`}/>
</Typography.Link>
</Popconfirm> : <Typography.Link>
<PlayCircleOutlined style={{color: token.colorWarning}} title={t`Enable the Watchlist`}
onClick={async () => await patchWatchlist(watchlist.token, {enabled: !enabled}).then(onChange)}/>
</Typography.Link>
)
}

View File

@ -5,6 +5,7 @@ import React, {useState} from 'react'
import {EditOutlined} from '@ant-design/icons' import {EditOutlined} from '@ant-design/icons'
import type {Connector} from '../../../utils/api/connectors' import type {Connector} from '../../../utils/api/connectors'
import type {Watchlist} from '../../../utils/api' import type {Watchlist} from '../../../utils/api'
import useBreakpoint from "../../../hooks/useBreakpoint"
export function UpdateWatchlistButton({watchlist, onUpdateWatchlist, connectors}: { export function UpdateWatchlistButton({watchlist, onUpdateWatchlist, connectors}: {
watchlist: Watchlist watchlist: Watchlist
@ -14,10 +15,9 @@ export function UpdateWatchlistButton({watchlist, onUpdateWatchlist, connectors}
const [form] = Form.useForm() const [form] = Form.useForm()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const sm = useBreakpoint('sm')
const showDrawer = () => { const showDrawer = () => setOpen(true)
setOpen(true)
}
const onClose = () => { const onClose = () => {
setOpen(false) setOpen(false)
@ -44,7 +44,7 @@ export function UpdateWatchlistButton({watchlist, onUpdateWatchlist, connectors}
</Typography.Link> </Typography.Link>
<Drawer <Drawer
title={t`Update a Watchlist`} title={t`Update a Watchlist`}
width='80%' width={sm ? '100%' : '80%'}
onClose={onClose} onClose={onClose}
open={open} open={open}
loading={loading} loading={loading}

View File

@ -17,8 +17,9 @@ import {actionToColor} from '../../../utils/functions/actionToColor'
import {DomainToTag} from '../../../utils/functions/DomainToTag' import {DomainToTag} from '../../../utils/functions/DomainToTag'
import type {Watchlist} from '../../../utils/api' import type {Watchlist} from '../../../utils/api'
import {eppStatusCodeToColor} from "../../../utils/functions/eppStatusCodeToColor" import {eppStatusCodeToColor} from "../../../utils/functions/eppStatusCodeToColor"
import {DisableWatchlistButton} from "./DisableWatchlistButton"
export function WatchlistCard({watchlist, onUpdateWatchlist, connectors, onDelete}: { export function WatchlistCard({watchlist, onUpdateWatchlist, connectors, onChange}: {
watchlist: Watchlist watchlist: Watchlist
onUpdateWatchlist: (values: { onUpdateWatchlist: (values: {
domains: string[], domains: string[],
@ -27,7 +28,7 @@ export function WatchlistCard({watchlist, onUpdateWatchlist, connectors, onDelet
token: string token: string
}) => Promise<void> }) => Promise<void>
connectors: Array<Connector & { id: string }> connectors: Array<Connector & { id: string }>
onDelete: () => void onChange: () => void
}) { }) {
const rdapEventNameTranslated = rdapEventNameTranslation() const rdapEventNameTranslated = rdapEventNameTranslation()
const rdapEventDetailTranslated = rdapEventDetailTranslation() const rdapEventDetailTranslated = rdapEventDetailTranslation()
@ -36,7 +37,14 @@ export function WatchlistCard({watchlist, onUpdateWatchlist, connectors, onDelet
return ( return (
<> <>
<Card <Card
aria-disabled={true}
type='inner' type='inner'
style={{
width: '100%',
opacity: watchlist.enabled ? 1 : 0.5,
filter: watchlist.enabled ? 'none' : 'grayscale(0.7)',
transition: 'all 0.3s ease',
}}
title={<> title={<>
{ {
(watchlist.connector != null) (watchlist.connector != null)
@ -52,7 +60,6 @@ export function WatchlistCard({watchlist, onUpdateWatchlist, connectors, onDelet
</Tooltip> </Tooltip>
</>} </>}
size='small' size='small'
style={{width: '100%'}}
extra={ extra={
<Space size='middle'> <Space size='middle'>
<ViewDiagramWatchlistButton token={watchlist.token}/> <ViewDiagramWatchlistButton token={watchlist.token}/>
@ -65,7 +72,9 @@ export function WatchlistCard({watchlist, onUpdateWatchlist, connectors, onDelet
connectors={connectors} connectors={connectors}
/> />
<DeleteWatchlistButton watchlist={watchlist} onDelete={onDelete}/> <DisableWatchlistButton watchlist={watchlist} onChange={onChange}
enabled={watchlist.enabled}/>
<DeleteWatchlistButton watchlist={watchlist} onDelete={onChange}/>
</Space> </Space>
} }
> >

View File

@ -3,21 +3,21 @@ import type {Connector} from '../../../utils/api/connectors'
import {WatchlistCard} from './WatchlistCard' import {WatchlistCard} from './WatchlistCard'
import type {Watchlist} from '../../../utils/api' import type {Watchlist} from '../../../utils/api'
export function WatchlistsList({watchlists, onDelete, onUpdateWatchlist, connectors}: { export function WatchlistsList({watchlists, onChange, onUpdateWatchlist, connectors}: {
watchlists: Watchlist[] watchlists: Watchlist[]
onDelete: () => void onChange: () => void
onUpdateWatchlist: (values: { domains: string[], trackedEvents: string[], trackedEppStatus: string[], token: string }) => Promise<void> onUpdateWatchlist: (values: { domains: string[], trackedEvents: string[], trackedEppStatus: string[], token: string }) => Promise<void>
connectors: Array<Connector & { id: string }> connectors: Array<Connector & { id: string }>
}) { }) {
return ( return (
<> <>
{watchlists.map(watchlist => {[...watchlists.filter(w => w.enabled), ...watchlists.filter(w => !w.enabled)].map(watchlist =>
<WatchlistCard <WatchlistCard
key={watchlist.token} key={watchlist.token}
watchlist={watchlist} watchlist={watchlist}
onUpdateWatchlist={onUpdateWatchlist} onUpdateWatchlist={onUpdateWatchlist}
connectors={connectors} connectors={connectors}
onDelete={onDelete} onChange={onChange}
/> />
)} )}
</> </>

View File

@ -1,15 +1,15 @@
import React, {useEffect, useState} from 'react' import React, {useEffect, useState} from 'react'
import {Card, Divider, Flex, Form, message} from 'antd' import {Divider, Flex, Form, message} from 'antd'
import type {Watchlist} from '../../utils/api' import type {Watchlist} from '../../utils/api'
import {getWatchlists, postWatchlist, putWatchlist} from '../../utils/api' import {getWatchlists, postWatchlist, putWatchlist} from '../../utils/api'
import type {AxiosError} from 'axios' import type {AxiosError} from 'axios'
import {t} from 'ttag' import {t} from 'ttag'
import {WatchlistForm} from '../../components/tracking/watchlist/WatchlistForm'
import {WatchlistsList} from '../../components/tracking/watchlist/WatchlistsList' import {WatchlistsList} from '../../components/tracking/watchlist/WatchlistsList'
import type {Connector} from '../../utils/api/connectors' import type {Connector} from '../../utils/api/connectors'
import { getConnectors} from '../../utils/api/connectors' import { getConnectors} from '../../utils/api/connectors'
import {showErrorAPI} from '../../utils/functions/showErrorAPI' import {showErrorAPI} from '../../utils/functions/showErrorAPI'
import {CreateWatchlistButton} from "../../components/tracking/watchlist/CreateWatchlistButton"
interface FormValuesType { interface FormValuesType {
name?: string name?: string
@ -20,17 +20,15 @@ interface FormValuesType {
dsn?: string[] dsn?: string[]
} }
const getRequestDataFromFormCreation = (values: FormValuesType) => { const getRequestDataFromFormCreation = (values: FormValuesType) =>
const domainsURI = values.domains.map(d => '/api/domains/' + d.toLowerCase()) ({ name: values.name,
return { domains: values.domains.map(d => '/api/domains/' + d.toLowerCase()),
name: values.name,
domains: domainsURI,
trackedEvents: values.trackedEvents, trackedEvents: values.trackedEvents,
trackedEppStatus: values.trackedEppStatus, trackedEppStatus: values.trackedEppStatus,
connector: values.connector !== undefined ? ('/api/connectors/' + values.connector) : undefined, connector: values.connector !== undefined ? ('/api/connectors/' + values.connector) : undefined,
dsn: values.dsn dsn: values.dsn,
} enabled: true
} })
export default function WatchlistPage() { export default function WatchlistPage() {
const [form] = Form.useForm() const [form] = Form.useForm()
@ -38,15 +36,13 @@ export default function WatchlistPage() {
const [watchlists, setWatchlists] = useState<Watchlist[]>() const [watchlists, setWatchlists] = useState<Watchlist[]>()
const [connectors, setConnectors] = useState<Array<Connector & { id: string }>>() const [connectors, setConnectors] = useState<Array<Connector & { id: string }>>()
const onCreateWatchlist = (values: FormValuesType) => { const onCreateWatchlist = async (values: FormValuesType) => await postWatchlist(getRequestDataFromFormCreation(values)).then(() => {
postWatchlist(getRequestDataFromFormCreation(values)).then(() => {
form.resetFields() form.resetFields()
refreshWatchlists() refreshWatchlists()
messageApi.success(t`Watchlist created !`) messageApi.success(t`Watchlist created !`)
}).catch((e: AxiosError) => { }).catch((e: AxiosError) => {
showErrorAPI(e, messageApi) showErrorAPI(e, messageApi)
}) })
}
const onUpdateWatchlist = async (values: FormValuesType & { token: string }) => await putWatchlist({ const onUpdateWatchlist = async (values: FormValuesType & { token: string }) => await putWatchlist({
token: values.token, token: values.token,
@ -78,18 +74,18 @@ export default function WatchlistPage() {
return ( return (
<Flex gap='middle' align='center' justify='center' vertical> <Flex gap='middle' align='center' justify='center' vertical>
{contextHolder} {contextHolder}
<Card size='small' loading={connectors === undefined} title={t`Create a Watchlist`} style={{width: '100%'}}>
{(connectors != null) &&
<WatchlistForm form={form} onFinish={onCreateWatchlist} connectors={connectors} isCreation/>}
</Card>
<Divider/>
{(connectors != null) && (watchlists != null) && watchlists.length > 0 && {(connectors != null) && (watchlists != null) && watchlists.length > 0 &&
<WatchlistsList <>
watchlists={watchlists} <CreateWatchlistButton onUpdateWatchlist={onCreateWatchlist} connectors={connectors} />
onDelete={refreshWatchlists} <Divider/>
connectors={connectors} <WatchlistsList
onUpdateWatchlist={onUpdateWatchlist} watchlists={watchlists}
/>} onChange={refreshWatchlists}
connectors={connectors}
onUpdateWatchlist={onUpdateWatchlist}
/>
</>}
</Flex> </Flex>
) )
} }

View File

@ -84,6 +84,7 @@ export interface WatchlistRequest {
trackedEppStatus?: string[] trackedEppStatus?: string[]
connector?: string connector?: string
dsn?: string[] dsn?: string[]
enabled?: boolean
} }
export interface Watchlist { export interface Watchlist {
@ -100,6 +101,7 @@ export interface Watchlist {
createdAt: string createdAt: string
} }
createdAt: string createdAt: string
enabled: boolean
} }
export interface InstanceConfig { export interface InstanceConfig {

View File

@ -32,6 +32,18 @@ export async function postWatchlist(watchlist: WatchlistRequest) {
return response.data return response.data
} }
export async function patchWatchlist(token: string, watchlist: Partial<WatchlistRequest>) {
const response = await request<{ token: string }>({
method: 'PATCH',
url: 'watchlists/' + token,
data: watchlist,
headers: {
'Content-Type': 'application/merge-patch+json'
}
})
return response.data
}
export async function deleteWatchlist(token: string): Promise<void> { export async function deleteWatchlist(token: string): Promise<void> {
await request({ await request({
method: 'DELETE', method: 'DELETE',

View File

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20251025160938 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add enabled flag on watchlist';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE watchlist ADD enabled BOOLEAN NOT NULL DEFAULT true');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE watchlist DROP enabled');
}
}

View File

@ -6,6 +6,7 @@ use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete; use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put; use ApiPlatform\Metadata\Put;
use App\Repository\WatchlistRepository; use App\Repository\WatchlistRepository;
@ -95,6 +96,12 @@ use Symfony\Component\Validator\Constraints as Assert;
security: 'object.getUser() == user', security: 'object.getUser() == user',
processor: WatchlistUpdateProcessor::class, processor: WatchlistUpdateProcessor::class,
), ),
new Patch(
normalizationContext: ['groups' => 'watchlist:list'],
denormalizationContext: ['groups' => ['watchlist:update']],
security: 'object.getUser() == user',
processor: WatchlistUpdateProcessor::class,
),
new Delete( new Delete(
security: 'object.getUser() == user' security: 'object.getUser() == user'
), ),
@ -208,6 +215,10 @@ class Watchlist
])] ])]
private array $trackedEppStatus = []; private array $trackedEppStatus = [];
#[ORM\Column(type: Types::BOOLEAN)]
#[Groups(['watchlist:list', 'watchlist:item', 'watchlist:create', 'watchlist:update'])]
private ?bool $enabled = null;
public function __construct() public function __construct()
{ {
$this->token = Uuid::v4(); $this->token = Uuid::v4();
@ -332,4 +343,16 @@ class Watchlist
return $this; return $this;
} }
public function isEnabled(): ?bool
{
return $this->enabled;
}
public function setEnabled(bool $enabled): static
{
$this->enabled = $enabled;
return $this;
}
} }

View File

@ -31,7 +31,7 @@ final readonly class ProcessWatchlistTriggerHandler
*/ */
$randomizer = new Randomizer(); $randomizer = new Randomizer();
$watchlists = $randomizer->shuffleArray($this->watchlistRepository->findAll()); $watchlists = $randomizer->shuffleArray($this->watchlistRepository->getEnabledWatchlist());
/** @var Watchlist $watchlist */ /** @var Watchlist $watchlist */
foreach ($watchlists as $watchlist) { foreach ($watchlists as $watchlist) {

View File

@ -25,6 +25,14 @@ class WatchlistRepository extends ServiceEntityRepository
->getQuery()->getSingleScalarResult(); ->getQuery()->getSingleScalarResult();
} }
public function getEnabledWatchlist()
{
return $this->createQueryBuilder('w')
->select()
->where('w.isEnabled = true')
->getQuery()->execute();
}
// /** // /**
// * @return Watchlist[] Returns an array of Watchlist objects // * @return Watchlist[] Returns an array of Watchlist objects
// */ // */

View File

@ -46,6 +46,7 @@ final class WatchlistUpdateProcessorTest extends ApiTestCase
'name' => 'My modified Watchlist', 'name' => 'My modified Watchlist',
'trackedEvents' => ['last changed'], 'trackedEvents' => ['last changed'],
'trackedEppStatus' => [], 'trackedEppStatus' => [],
'enabled' => true
]]); ]]);
$this->assertResponseIsSuccessful(); $this->assertResponseIsSuccessful();
$this->assertMatchesResourceItemJsonSchema(Watchlist::class); $this->assertMatchesResourceItemJsonSchema(Watchlist::class);
@ -64,6 +65,7 @@ final class WatchlistUpdateProcessorTest extends ApiTestCase
'trackedEvents' => ['last changed', 'transfer', 'expiration', 'deletion'], 'trackedEvents' => ['last changed', 'transfer', 'expiration', 'deletion'],
'trackedEppStatus' => ['redemption period', 'pending delete', 'client hold', 'server hold'], 'trackedEppStatus' => ['redemption period', 'pending delete', 'client hold', 'server hold'],
'connector' => $connectorId, 'connector' => $connectorId,
'enabled' => true,
]]); ]]);
return $client; return $client;