Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion assets/controllers/notifications_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Controller } from '@hotwired/stimulus';
/* Cloche topbar : dropdown de la pile unifiée de notifications (messages
* directs + changelog, #373). Modèle : new_menu_controller.js. */
export default class extends Controller {
static targets = ['button', 'panel', 'badge'];
static targets = ['button', 'panel', 'badge', 'item'];

connect() {
this._onDocumentClick = (e) => {
Expand Down Expand Up @@ -85,6 +85,20 @@ export default class extends Controller {
this._decrementBadge();
}

// #533 : "Tout marquer comme lu" — orchestre les deux mécanismes de
// lecture déjà en place (lastChangelogViewedAt + DirectMessage.readAt)
// en un seul appel, puis vide le panel entier d'un coup plutôt qu'un
// item à la fois (_removeAfter accepte déjà une liste d'items).
markAllRead(event) {
event.stopPropagation();

const items = [...this.itemTargets];
this._removeAfter(fetch('/notifications/mark-all-read', { method: 'POST' }), items);
if (this.hasBadgeTarget) {
this.badgeTarget.remove();
}
}

// La pastille (#468) est rendue une seule fois côté serveur au
// chargement de la page (NotificationDropdown.html.twig) — sans ce
// décrément, elle restait bloquée à sa valeur initiale jusqu'au
Expand Down
20 changes: 20 additions & 0 deletions assets/styles/notifications.css
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,26 @@
text-align: center;
}

.hc-notif-header {
display: flex;
justify-content: flex-end;
padding: 0.4rem 0.75rem;
border-bottom: 1px solid var(--hc-border);
}

.hc-notif-mark-all-read {
background: none;
border: none;
padding: 0;
font-size: 0.75rem;
color: var(--hc-accent);
cursor: pointer;
}

.hc-notif-mark-all-read:hover {
text-decoration: underline;
}

.hc-notif-item {
display: flex;
align-items: flex-start;
Expand Down
60 changes: 60 additions & 0 deletions assets/tests/notifications-controller.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { jest, describe, test, expect, beforeEach, afterEach } from '@jest/globals';
import { Application } from '@hotwired/stimulus';
import NotificationsController from '../controllers/notifications_controller.js';

/**
* #533 : "Tout marquer comme lu" — un clic vide le panel entier et retire
* le badge, sans devoir marquer chaque item un par un.
*/
describe('notifications controller (#533)', () => {
let application;

function html() {
document.body.innerHTML = `
<div data-controller="notifications">
<button data-notifications-target="button" data-action="click->notifications#toggle"></button>
<span data-notifications-target="badge">2</span>
<div data-notifications-target="panel">
<button data-action="click->notifications#markAllRead">Tout marquer comme lu</button>
<a href="/x" data-notifications-target="item" data-notifications-type-param="direct_message" data-notifications-id-param="1">Item 1</a>
<a href="/y" data-notifications-target="item" data-notifications-type-param="changelog">Item 2</a>
</div>
</div>
`;
}

beforeEach(() => {
html();
application = Application.start();
application.register('notifications', NotificationsController);
});

afterEach(() => {
application.stop();
jest.restoreAllMocks();
delete global.fetch;
});

test('appelle /notifications/mark-all-read et retire tous les items du panel', async () => {
global.fetch = jest.fn().mockResolvedValue({ ok: true });

const button = document.querySelector('[data-action="click->notifications#markAllRead"]');
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));

expect(global.fetch).toHaveBeenCalledWith('/notifications/mark-all-read', { method: 'POST' });

await new Promise((resolve) => setTimeout(resolve, 0));

const items = document.querySelectorAll('[data-notifications-target="item"]');
items.forEach((item) => expect(item.classList.contains('hc-notif-item--removing')).toBe(true));
});

test('retire le badge immédiatement', () => {
global.fetch = jest.fn().mockResolvedValue({ ok: true });

const button = document.querySelector('[data-action="click->notifications#markAllRead"]');
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));

expect(document.querySelector('[data-notifications-target="badge"]')).toBeNull();
});
});
39 changes: 39 additions & 0 deletions src/Controller/Web/NotificationMarkAllReadWebController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

declare(strict_types=1);

namespace App\Controller\Web;

use App\Entity\User;
use App\Repository\DirectMessageRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;

/**
* "Tout marquer comme lu" (#533) — orchestre les deux mécanismes de lecture
* déjà en place (lastChangelogViewedAt + DirectMessage.readAt), pas de
* nouvelle table ni de refonte de la pile virtuelle de notifications (#373).
*/
#[IsGranted('ROLE_USER')]
final class NotificationMarkAllReadWebController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly DirectMessageRepository $directMessageRepository,
) {}

#[Route('/notifications/mark-all-read', name: 'app_notifications_mark_all_read', methods: ['POST'])]
public function __invoke(): Response
{
/** @var User $user */
$user = $this->getUser();
$user->setLastChangelogViewedAt(new \DateTimeImmutable());
$this->directMessageRepository->markAllAsReadForRecipient($user);
$this->em->flush();

return new Response(status: 204);
}
}
16 changes: 16 additions & 0 deletions src/Repository/DirectMessageRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,20 @@ public function findForUser(User $user): array
->getQuery()
->getResult();
}

// #533 : "Tout marquer comme lu" — UPDATE bulk plutôt qu'un foreach +
// markAsRead() + flush par message (potentiellement des dizaines de
// messages non lus d'un coup).
public function markAllAsReadForRecipient(User $recipient): void
{
$this->createQueryBuilder('dm')
->update()
->set('dm.readAt', ':now')
->andWhere('dm.recipient = :userId')
->andWhere('dm.readAt IS NULL')
->setParameter('now', new \DateTimeImmutable(), \Doctrine\DBAL\Types\Types::DATETIME_IMMUTABLE)
->setParameter('userId', $recipient->getId(), 'uuid')
->getQuery()
->execute();
}
}
7 changes: 7 additions & 0 deletions templates/components/NotificationDropdown.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@
{% if notificationItems is empty %}
<p class="hc-notif-empty">Aucune notification pour le moment.</p>
{% else %}
<div class="hc-notif-header">
<button type="button"
class="hc-notif-mark-all-read"
data-action="click->notifications#markAllRead">
Tout marquer comme lu
</button>
</div>
{% for item in notificationItems %}
<a href="{{ item.link }}"
class="hc-notif-item hc-notif-item--unread"
Expand Down
68 changes: 68 additions & 0 deletions tests/Repository/DirectMessageRepositoryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -125,4 +125,72 @@ public function testCountUnreadForUserCountsOnlyUnreadMessages(): void

$this->assertSame(1, $this->repository->countUnreadForUser($freshRecipient));
}

// #533 : "Tout marquer comme lu" en un clic — jusqu'ici seul un marquage
// unitaire existait (DirectMessageReadWebController), rien n'orchestrait
// le marquage en masse de tous les messages non lus d'un destinataire.
public function testMarkAllAsReadForRecipientMarksAllUnreadMessages(): void
{
$sender = $this->createUser('sender-markall@example.com');
$recipient = $this->createUser('recipient-markall@example.com');

$first = new DirectMessage($sender, $recipient, 'Premier', 'Corps');
$second = new DirectMessage($sender, $recipient, 'Second', 'Corps');
$this->em->persist($first);
$this->em->persist($second);
$this->em->flush();
$this->em->clear();

$freshRecipient = $this->em->getRepository(User::class)->find($recipient->getId());
$this->repository->markAllAsReadForRecipient($freshRecipient);
$this->em->clear();

$freshRecipient = $this->em->getRepository(User::class)->find($recipient->getId());
$this->assertSame(0, $this->repository->countUnreadForUser($freshRecipient));
}

public function testMarkAllAsReadForRecipientDoesNotAffectOtherRecipients(): void
{
$sender = $this->createUser('sender-markall-other@example.com');
$recipient = $this->createUser('recipient-markall-mine@example.com');
$other = $this->createUser('recipient-markall-other@example.com');

$mine = new DirectMessage($sender, $recipient, 'Le mien', 'Corps');
$theirs = new DirectMessage($sender, $other, 'Le leur', 'Corps');
$this->em->persist($mine);
$this->em->persist($theirs);
$this->em->flush();
$this->em->clear();

$freshRecipient = $this->em->getRepository(User::class)->find($recipient->getId());
$this->repository->markAllAsReadForRecipient($freshRecipient);
$this->em->clear();

$freshOther = $this->em->getRepository(User::class)->find($other->getId());
$this->assertSame(1, $this->repository->countUnreadForUser($freshOther));
}

public function testMarkAllAsReadForRecipientDoesNotOverwriteAlreadyReadTimestamp(): void
{
$sender = $this->createUser('sender-markall-keep@example.com');
$recipient = $this->createUser('recipient-markall-keep@example.com');

$alreadyRead = new DirectMessage($sender, $recipient, 'Déjà lu', 'Corps');
$alreadyRead->markAsRead();
$this->em->persist($alreadyRead);
$this->em->flush();
$this->em->clear();

// Rechargé après le premier flush (précision DB, pas microsecondes)
// pour comparer contre la même précision après markAllAsReadForRecipient.
$originalReadAt = $this->em->getRepository(DirectMessage::class)->find($alreadyRead->getId())->getReadAt();
$this->em->clear();

$freshRecipient = $this->em->getRepository(User::class)->find($recipient->getId());
$this->repository->markAllAsReadForRecipient($freshRecipient);
$this->em->clear();

$reloaded = $this->em->getRepository(DirectMessage::class)->find($alreadyRead->getId());
$this->assertEquals($originalReadAt, $reloaded->getReadAt());
}
}
107 changes: 107 additions & 0 deletions tests/Web/NotificationMarkAllReadWebControllerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
<?php

declare(strict_types=1);

namespace App\Tests\Web;

use App\Entity\DirectMessage;
use App\Entity\User;
use App\Tests\Web\Fixtures\WebFixturesTrait;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

/**
* TDD RED → GREEN : "Tout marquer comme lu" en un clic dans le dropdown de
* notifications (#533) — orchestre les deux mécanismes de lecture déjà en
* place (lastChangelogViewedAt + DirectMessage.readAt), sans nouvelle table
* ni refonte de la pile virtuelle (#373).
*/
final class NotificationMarkAllReadWebControllerTest extends WebTestCase
{
use WebFixturesTrait;

private EntityManagerInterface $em;
private \Symfony\Bundle\FrameworkBundle\KernelBrowser $client;

protected function setUp(): void
{
$this->client = static::createClient();
$this->em = static::getContainer()->get(EntityManagerInterface::class);
$conn = $this->em->getConnection();
$conn->executeStatement('SET FOREIGN_KEY_CHECKS=0');
$conn->executeStatement('DELETE FROM direct_messages');
$conn->executeStatement('DELETE FROM users');
$conn->executeStatement('SET FOREIGN_KEY_CHECKS=1');
$this->em->clear();
}

public function testRejectsAnonymousUser(): void
{
$this->client->request('POST', '/notifications/mark-all-read');

$this->assertResponseRedirects('/login');
}

public function testMarksChangelogAsViewed(): void
{
$user = $this->createWebUser('user-changelog@example.com', 'secret123', 'User');
$this->loginAs('user-changelog@example.com');

$this->assertNull($user->getLastChangelogViewedAt());

$this->client->request('POST', '/notifications/mark-all-read');

$this->assertResponseIsSuccessful();

$this->em->clear();
$reloaded = $this->em->getRepository(User::class)->find($user->getId());
$this->assertNotNull($reloaded->getLastChangelogViewedAt());
}

public function testMarksAllDirectMessagesAsRead(): void
{
$sender = $this->createWebUser('sender@example.com', 'secret123', 'Sender');
$recipient = $this->createWebUser('recipient@example.com', 'secret123', 'Recipient');
$this->loginAs('recipient@example.com');

$this->em = static::getContainer()->get(EntityManagerInterface::class);
$sender = $this->em->getRepository(User::class)->find($sender->getId());
$recipient = $this->em->getRepository(User::class)->find($recipient->getId());

$message = new DirectMessage($sender, $recipient, 'Sujet', 'Corps');
$this->em->persist($message);
$this->em->flush();
$this->em->clear();

$this->client->request('POST', '/notifications/mark-all-read');

$this->assertResponseIsSuccessful();

$reloaded = $this->em->getRepository(DirectMessage::class)->find($message->getId());
$this->assertNotNull($reloaded->getReadAt());
}

public function testDoesNotAffectAnotherUsersMessages(): void
{
$sender = $this->createWebUser('sender-isolated@example.com', 'secret123', 'Sender');
$recipient = $this->createWebUser('recipient-isolated@example.com', 'secret123', 'Recipient');
$other = $this->createWebUser('other-isolated@example.com', 'secret123', 'Other');
$this->loginAs('recipient-isolated@example.com');

$this->em = static::getContainer()->get(EntityManagerInterface::class);
$sender = $this->em->getRepository(User::class)->find($sender->getId());
$other = $this->em->getRepository(User::class)->find($other->getId());

$theirs = new DirectMessage($sender, $other, 'Pas le mien', 'Corps');
$this->em->persist($theirs);
$this->em->flush();
$this->em->clear();

$this->client->request('POST', '/notifications/mark-all-read');

$this->assertResponseIsSuccessful();

$reloaded = $this->em->getRepository(DirectMessage::class)->find($theirs->getId());
$this->assertNull($reloaded->getReadAt());
}
}
2 changes: 1 addition & 1 deletion var/tailwind/app.built.css

Large diffs are not rendered by default.

Loading