mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 01:54:10 +00:00
feat(insights): the radar — cards in Happening Soon and above the conversation
Dated insights join the panel as cards (kind chip, status pill, Track, open-the-mail, dismiss); undated GitHub activity gets its own section. Dismissal is a CSRF'd POST through OwnershipVoter — MailInsight joins the voter rather than growing the twentieth hand-rolled ownership check — and the reading pane shows a compact strip for the conversation's own insights, deliberately without a dismiss: hiding a radar card is one thing, hiding a fact stated in the mail you are reading is another. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
506a3aad5e
commit
38b9a40ab9
11 files changed
+962
-81
No files matched your search
@@ -0,0 +1,60 @@
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
/**
|
||||
* The radar's dismiss button — POSTs the wave-away, then takes the card off
|
||||
* the screen.
|
||||
*
|
||||
* The token rides in as a Stimulus VALUE from the template rather than through
|
||||
* csrf.js, because it is the per-action `insight-dismiss` token the endpoint
|
||||
* checks, not the shared `ajax` one — the same split csrf.js itself documents
|
||||
* for settings/account_order_controller. One controller scope wraps the whole
|
||||
* radar, so the token is minted once and every card's button reaches it; the
|
||||
* URL differs per card and travels as an action param on the button.
|
||||
*
|
||||
* The card is removed only AFTER the server says ok. An optimistic removal
|
||||
* would eat the card on an expired session and hand it back on the next open,
|
||||
* which reads as the dismissal "not sticking" — the one behaviour the
|
||||
* dismissedAt column exists to rule out (see MailInsight).
|
||||
*/
|
||||
export default class extends Controller {
|
||||
static values = { token: String };
|
||||
|
||||
async dismiss(event) {
|
||||
const button = event.currentTarget;
|
||||
const card = button.closest("[data-insight-card]");
|
||||
|
||||
// No double-fire from a slow network's second click. The endpoint is
|
||||
// idempotent anyway; this is about not looking broken, not safety.
|
||||
button.disabled = true;
|
||||
|
||||
let response;
|
||||
|
||||
try {
|
||||
response = await fetch(event.params.url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": this.tokenValue,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
|
||||
if (!response.ok || !card) {
|
||||
return;
|
||||
}
|
||||
|
||||
const list = card.closest("[data-insight-list]");
|
||||
|
||||
card.remove();
|
||||
|
||||
// A subheading over nothing reads as a section that failed to load,
|
||||
// so the last card takes its section with it.
|
||||
if (list && !list.querySelector("[data-insight-card]")) {
|
||||
(list.closest("[data-insight-section]") ?? list).remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,10 @@ declare(strict_types=1);
|
||||
namespace App\Controller\Calendar;
|
||||
|
||||
use App\Entity\User\User;
|
||||
use App\Repository\Insight\MailInsightRepository;
|
||||
use App\Service\Calendar\HappeningSoonReader;
|
||||
use DateTimeImmutable;
|
||||
use DateTimeZone;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
@@ -53,6 +56,7 @@ final class HappeningSoonController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly HappeningSoonReader $reader,
|
||||
private readonly MailInsightRepository $insights,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -68,8 +72,23 @@ final class HappeningSoonController extends AbstractController
|
||||
#[Route('/soon', name: 'soon', methods: ['GET'])]
|
||||
public function panel(): Response
|
||||
{
|
||||
$user = $this->currentUser();
|
||||
$now = new DateTimeImmutable('now', new DateTimeZone('UTC'));
|
||||
|
||||
// The radar reads straight off MailInsightRepository rather than
|
||||
// growing a reader of its own or moving into HappeningSoonReader. That
|
||||
// reader earns its layer by deciding things before a template may look
|
||||
// — windows counted in occurrences, clustering, provenance resolved in
|
||||
// one query — and its class doc is entirely about calendar rows. An
|
||||
// insight arrives already shaped: windowed, ordered, capped and
|
||||
// one-row-per-parcel by construction in the repository, so a service
|
||||
// between here and there would be a pass-through with no decision in
|
||||
// it, and folding it into the reader would make that class about two
|
||||
// families it keeps apart on purpose (see InsightKind's doc).
|
||||
return $this->render('calendar/_happening_soon.html.twig', [
|
||||
'rows' => $this->reader->read($this->currentUser()),
|
||||
'rows' => $this->reader->read($user),
|
||||
'insights' => $this->insights->upcomingForUser($user, $now),
|
||||
'undatedInsights' => $this->insights->recentUndatedForUser($user, $now),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Insight;
|
||||
|
||||
use App\Controller\ChecksCsrf;
|
||||
use App\Entity\Insight\MailInsight;
|
||||
use App\Security\Voter\OwnershipVoter;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* The user's verbs on an insight. There is exactly one: waving it away.
|
||||
*
|
||||
* Dismissal is a write to a column rather than a delete, and that is the
|
||||
* entity's decision, not this controller's — MailInsight::$dismissedAt records
|
||||
* at length why the row must survive its own dismissal (the dedupe key keeps
|
||||
* absorbing the carrier's follow-up mails silently; deleting the row would
|
||||
* resurrect the card on the next status update). This endpoint only stamps the
|
||||
* time.
|
||||
*
|
||||
* A JSON POST rather than a form: the button lives on a card inside the
|
||||
* Happening Soon panel, which is a turbo-frame fragment — a form submission
|
||||
* would navigate the frame, and the whole point of dismissing is that the card
|
||||
* leaves and everything else stays. The Stimulus side is
|
||||
* assets/controllers/insight/radar_controller.js.
|
||||
*/
|
||||
#[Route('/insights', name: 'app_insight_')]
|
||||
#[IsGranted('IS_AUTHENTICATED')]
|
||||
final class InsightActionsController extends AbstractController
|
||||
{
|
||||
use ChecksCsrf;
|
||||
|
||||
/**
|
||||
* `{id}` constrained to digits for the reason MailController::thread()
|
||||
* states: a non-numeric id must 404 at the router rather than reach the
|
||||
* entity resolver as a bigint that is not one.
|
||||
*/
|
||||
#[Route('/{id}/dismiss', name: 'dismiss', requirements: ['id' => '\d+'], methods: ['POST'])]
|
||||
public function dismiss(MailInsight $insight, Request $request, EntityManagerInterface $em): JsonResponse
|
||||
{
|
||||
$this->assertCsrf($request, 'insight-dismiss');
|
||||
$this->denyAccessUnlessGranted(OwnershipVoter::OWN, $insight);
|
||||
|
||||
// Idempotent: the second click of a slow double-click keeps the first
|
||||
// click's timestamp rather than moving it.
|
||||
if (null === $insight->dismissedAt) {
|
||||
$insight->dismissedAt = new DateTimeImmutable();
|
||||
$em->flush();
|
||||
}
|
||||
|
||||
return $this->json(['ok' => true]);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ use App\Entity\Mail\Account;
|
||||
use App\Entity\Mail\Message;
|
||||
use App\Entity\Mail\MessageThread;
|
||||
use App\Entity\User\User;
|
||||
use App\Repository\Insight\MailInsightRepository;
|
||||
use App\Repository\Label\LabelRepository;
|
||||
use App\Repository\Mail\AccountRepository;
|
||||
use App\Repository\Mail\MailboxRepository;
|
||||
@@ -37,6 +38,7 @@ final class MailController extends AbstractController
|
||||
private readonly LabelRepository $labelRepository,
|
||||
private readonly AccountRepository $accountRepository,
|
||||
private readonly ThreadListRenderer $listRenderer,
|
||||
private readonly MailInsightRepository $insightRepository,
|
||||
)
|
||||
{
|
||||
}
|
||||
@@ -682,10 +684,15 @@ final class MailController extends AbstractController
|
||||
|
||||
$this->denyAccessUnlessGranted(OwnershipVoter::OWN, $account);
|
||||
|
||||
// The conversation's insight cards, drawn above the body — one indexed
|
||||
// read by thread, empty for almost every thread and cheap when it is.
|
||||
$insights = $this->insightRepository->forThread($thread);
|
||||
|
||||
if ($request->headers->get('X-Requested-With') === 'fetch') {
|
||||
// JS fetch — return just the message content fragment
|
||||
return $this->render('mail/_message_content.html.twig', [
|
||||
'message' => $message,
|
||||
'message' => $message,
|
||||
'insights' => $insights,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -695,6 +702,7 @@ final class MailController extends AbstractController
|
||||
'thread' => $thread,
|
||||
'account' => $account,
|
||||
'selectedMessage' => $message,
|
||||
'insights' => $insights,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -721,11 +729,15 @@ final class MailController extends AbstractController
|
||||
$messages = $this->messageRepository->forThreadInConversationOrder($thread);
|
||||
$latest = [] === $messages ? null : $messages[array_key_last($messages)];
|
||||
|
||||
// Same read as message() above, for the same card in the same place.
|
||||
$insights = $this->insightRepository->forThread($thread);
|
||||
|
||||
if ($request->headers->get('X-Requested-With') === 'fetch') {
|
||||
return $this->render('mail/_thread_content.html.twig', [
|
||||
'thread' => $thread,
|
||||
'messages' => $messages,
|
||||
'latest' => $latest,
|
||||
'insights' => $insights,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -734,6 +746,7 @@ final class MailController extends AbstractController
|
||||
'account' => $account,
|
||||
'messages' => $messages,
|
||||
'latest' => $latest,
|
||||
'insights' => $insights,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ namespace App\Security\Voter;
|
||||
|
||||
use App\Entity\Calendar\Calendar;
|
||||
use App\Entity\Calendar\CalendarEvent;
|
||||
use App\Entity\Insight\MailInsight;
|
||||
use App\Entity\Integration\Integration;
|
||||
use App\Entity\Label\Label;
|
||||
use App\Entity\Mail\Account;
|
||||
@@ -35,10 +36,11 @@ use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||
*
|
||||
* ── Why one voter and not one per entity ─────────────────────────────────────
|
||||
* The idiomatic shape is a voter per entity, and it was rejected here because
|
||||
* the rule genuinely does not vary: owning is `$owner === $user` for all nine
|
||||
* types. Nine classes would have been nine copies of that comparison with nine
|
||||
* different ways to reach `$owner`, which is the duplication this replaces
|
||||
* wearing a different hat. What varies is only how the owner is REACHED, so
|
||||
* the rule genuinely does not vary: owning is `$owner === $user` for every
|
||||
* type below. A class per type would have been that many copies of the same
|
||||
* comparison with as many different ways to reach `$owner`, which is the
|
||||
* duplication this replaces wearing a different hat. What varies is only how
|
||||
* the owner is REACHED, so
|
||||
* that — and only that — is the match below.
|
||||
*
|
||||
* Rules that are not ownership stay with their controller, because they are not
|
||||
@@ -77,6 +79,7 @@ final class OwnershipVoter extends Voter
|
||||
|| $subject instanceof CalendarEvent
|
||||
|| $subject instanceof Integration
|
||||
|| $subject instanceof Label
|
||||
|| $subject instanceof MailInsight
|
||||
|| $subject instanceof MailRule
|
||||
|| $subject instanceof Message
|
||||
|| $subject instanceof MessagePart
|
||||
@@ -131,6 +134,10 @@ final class OwnershipVoter extends Voter
|
||||
$subject instanceof CalendarEvent => $subject->usr,
|
||||
$subject instanceof Integration => $subject->usr,
|
||||
$subject instanceof Label => $subject->usr,
|
||||
// Through the account, like Message: an insight belongs to whoever
|
||||
// owns the mailbox it was read out of, and its $account is the one
|
||||
// link the mapping declares non-nullable.
|
||||
$subject instanceof MailInsight => $subject->account->usr,
|
||||
$subject instanceof MailRule => $subject->usr,
|
||||
$subject instanceof Message => $subject->account->usr,
|
||||
$subject instanceof MessagePart => $subject->message?->account->usr,
|
||||
|
||||
@@ -6,9 +6,13 @@ out of mail and what the owner put in the calendar themselves — loaded into th
|
||||
app's body-level modal frame.
|
||||
|
||||
Accepts:
|
||||
rows list<HappeningSoonRow> — already windowed, ordered, COLLAPSED and capped
|
||||
by HappeningSoonReader; this template windows nothing, sorts nothing and
|
||||
merges nothing
|
||||
rows list<HappeningSoonRow> — already windowed, ordered, COLLAPSED
|
||||
and capped by HappeningSoonReader; this template windows
|
||||
nothing, sorts nothing and merges nothing
|
||||
insights list<MailInsight> — the radar's dated cards, windowed and
|
||||
ordered by MailInsightRepository::upcomingForUser()
|
||||
undatedInsights list<MailInsight> — the undated family (GitHub activity),
|
||||
from recentUndatedForUser()
|
||||
|
||||
One meeting is one row. A meeting that reached plMail twice — extracted from its
|
||||
invitation onto the account's calendar, mirrored from the provider onto a Remote
|
||||
@@ -29,109 +33,309 @@ is unfalsifiable. That link is the whole reason extracted events are marked as
|
||||
extracted, and its absence on a hand-made row is the correct answer — nothing
|
||||
made that one but the person reading it.
|
||||
|
||||
Below the calendar list sits THE RADAR: the mail insights (MailInsight) whose
|
||||
whole point is that they are NOT calendar events — a parcel has an ETA days
|
||||
wide, a pull request has no date at all, and forcing either into an occurrence
|
||||
row would put fake times on real things (see InsightKind's doc). They share
|
||||
this panel rather than growing one of their own for the reason the controller
|
||||
records about pages: both answer "what is coming up?" and nobody would open two
|
||||
dialogs to ask it once. Cards rather than lines, because an insight carries
|
||||
verbs — track the parcel, open the mail, wave it away — and a line has nowhere
|
||||
to put them. An empty radar renders NOTHING, not an empty state: the calendar
|
||||
list above is the panel's answer, and a second "nothing here" under a real list
|
||||
would read as a fault.
|
||||
|
||||
The provenance link carries `data-turbo-frame="_top"`. Without it Turbo reads a
|
||||
link inside a <turbo-frame> as a navigation OF that frame, so clicking "from
|
||||
this email" would load the whole mailbox page into the dialog and leave the user
|
||||
inside a modal with no way back to their mail. The event editor's form documents
|
||||
the same trap from the other direction.
|
||||
the same trap from the other direction. The radar's "open the mail" link carries
|
||||
it for the same reason.
|
||||
|
||||
A row whose message is gone renders without the link rather than with a dead
|
||||
one. An event keeps its kind after the mail behind it is expunged
|
||||
provider-side, and a link to a message that is not there is worse than no link.
|
||||
The radar handles a thread gone the same way — MailInsight::$thread is SET NULL
|
||||
on delete, and the card simply loses its mail link.
|
||||
|
||||
The empty state is written as an answer, not as an absence. "Nothing coming up"
|
||||
plus a line saying what would appear here — a panel that opens on blank space
|
||||
reads as a panel that failed to load, and this one is empty for most people on
|
||||
most days, which is the normal case and must not look like a fault.
|
||||
most days, which is the normal case and must not look like a fault. It shows
|
||||
only when EVERYTHING is empty: an empty calendar over a live radar is not an
|
||||
empty panel.
|
||||
#}
|
||||
|
||||
{% set rows = rows|default([]) %}
|
||||
{% set insights = insights|default([]) %}
|
||||
{% set undatedInsights = undatedInsights|default([]) %}
|
||||
|
||||
<turbo-frame id="modal" class="flex-1 min-h-0 flex flex-col">
|
||||
|
||||
{% if rows is empty %}
|
||||
{% if rows is empty and insights is empty and undatedInsights is empty %}
|
||||
<div class="flex flex-col items-center justify-center gap-2 px-6 pb-8 pt-4 text-center">
|
||||
<i class="fa-regular fa-calendar-check text-2xl text-ink-faint" aria-hidden="true"></i>
|
||||
<p class="text-sm text-ink">{{ 'calendar.soon.empty'|trans }}</p>
|
||||
<p class="text-xs text-ink-faint max-w-xs">{{ 'calendar.soon.empty_hint'|trans }}</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<ul class="flex-1 min-h-0 overflow-y-auto px-6 pb-5 divide-y divide-line">
|
||||
{% for row in rows %}
|
||||
{# Both fall back rather than rendering empty: a booking mail
|
||||
with no Subject: header is rare and a blank line where the
|
||||
provenance should be reads as a bug. #}
|
||||
{% set row_title = row.event.title ?: 'calendar.soon.untitled'|trans %}
|
||||
{% set row_from = 'calendar.soon.from'|trans({
|
||||
'%subject%': row.source ? (row.source.subject ?: 'calendar.soon.untitled'|trans) : '',
|
||||
}) %}
|
||||
{# One scroller for list and radar together — two nested scroll regions
|
||||
in a modal is the "why won't this move" bug in waiting. #}
|
||||
<div class="flex-1 min-h-0 overflow-y-auto px-6 pb-5">
|
||||
|
||||
{# Equal slices in the members' order, the same construction and
|
||||
the same reading as the merged chip's dot — one affordance
|
||||
learned once. Built only for a merged row: a cluster of one
|
||||
has a single calendar and a plain coloured dot would add a
|
||||
mark to every line for no information. #}
|
||||
{% if row.cluster.isMerged %}
|
||||
{% set slices = [] %}
|
||||
{% for color in row.cluster.colors %}
|
||||
{% set slices = slices|merge([
|
||||
color
|
||||
~ ' ' ~ (loop.index0 * 100 / row.cluster.colors|length)|round(3) ~ '%'
|
||||
~ ' ' ~ (loop.index * 100 / row.cluster.colors|length)|round(3) ~ '%',
|
||||
]) %}
|
||||
{% endfor %}
|
||||
{% set row_on = 'calendar.chip.on_calendars'|trans({
|
||||
'%calendars%': row.cluster.calendarNames|join(', '),
|
||||
}) %}
|
||||
{% endif %}
|
||||
|
||||
<li class="flex items-start gap-3 py-3">
|
||||
|
||||
{# The icon carries the kind's name as text for anyone who
|
||||
cannot see it, so the row still says "Flight" rather than
|
||||
leaving the shape to do all the work. #}
|
||||
<span class="relative mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center
|
||||
rounded-lg bg-sunken text-ink-muted">
|
||||
<i class="{{ row.icon }} text-sm" aria-hidden="true"></i>
|
||||
{% if rows is not empty %}
|
||||
<ul class="divide-y divide-line">
|
||||
{% for row in rows %}
|
||||
{# Both fall back rather than rendering empty: a booking mail
|
||||
with no Subject: header is rare and a blank line where the
|
||||
provenance should be reads as a bug. #}
|
||||
{% set row_title = row.event.title ?: 'calendar.soon.untitled'|trans %}
|
||||
{% set row_from = 'calendar.soon.from'|trans({
|
||||
'%subject%': row.source ? (row.source.subject ?: 'calendar.soon.untitled'|trans) : '',
|
||||
}) %}
|
||||
|
||||
{# Equal slices in the members' order, the same construction and
|
||||
the same reading as the merged chip's dot — one affordance
|
||||
learned once. Built only for a merged row: a cluster of one
|
||||
has a single calendar and a plain coloured dot would add a
|
||||
mark to every line for no information. #}
|
||||
{% if row.cluster.isMerged %}
|
||||
<span class="absolute -right-0.5 -top-0.5 h-2.5 w-2.5 rounded-full
|
||||
ring-2 ring-surface"
|
||||
style="background: conic-gradient({{ slices|join(', ') }})"
|
||||
title="{{ row_on }}"></span>
|
||||
<span class="sr-only">{{ row_on }}</span>
|
||||
{% set slices = [] %}
|
||||
{% for color in row.cluster.colors %}
|
||||
{% set slices = slices|merge([
|
||||
color
|
||||
~ ' ' ~ (loop.index0 * 100 / row.cluster.colors|length)|round(3) ~ '%'
|
||||
~ ' ' ~ (loop.index * 100 / row.cluster.colors|length)|round(3) ~ '%',
|
||||
]) %}
|
||||
{% endfor %}
|
||||
{% set row_on = 'calendar.chip.on_calendars'|trans({
|
||||
'%calendars%': row.cluster.calendarNames|join(', '),
|
||||
}) %}
|
||||
{% endif %}
|
||||
</span>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-medium text-ink" title="{{ row_title }}">
|
||||
{{ row_title }}
|
||||
</p>
|
||||
<li class="flex items-start gap-3 py-3">
|
||||
|
||||
<p class="mt-0.5 text-xs text-ink-muted">
|
||||
<span class="sr-only">{{ row.transKey|trans }} · </span>
|
||||
{# An all-day row is floating — a wall date at
|
||||
midnight with no zone — so `false` keeps it in
|
||||
its own, and the reader's clock does not move it
|
||||
onto the day before. #}
|
||||
{{ row.startsAt|intl_date('EEEdMMM', row.event.isAllDay ? false : null) }}
|
||||
{%- if not row.event.isAllDay %} · {{ row.startsAt|date(clock.time) }}{% endif %}
|
||||
</p>
|
||||
{# The icon carries the kind's name as text for anyone who
|
||||
cannot see it, so the row still says "Flight" rather than
|
||||
leaving the shape to do all the work. #}
|
||||
<span class="relative mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center
|
||||
rounded-lg bg-sunken text-ink-muted">
|
||||
<i class="{{ row.icon }} text-sm" aria-hidden="true"></i>
|
||||
|
||||
{% if row.source is not null %}
|
||||
<a href="{{ path('app_mail_message', { id: row.source.id }) }}"
|
||||
data-turbo-frame="_top"
|
||||
class="mt-1 inline-flex max-w-full items-center gap-1.5
|
||||
text-xs text-ink-faint hover:text-accent hover:underline"
|
||||
title="{{ row_from }}">
|
||||
<i class="fa-regular fa-envelope text-[10px] shrink-0" aria-hidden="true"></i>
|
||||
<span class="truncate">{{ row_from }}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% if row.cluster.isMerged %}
|
||||
<span class="absolute -right-0.5 -top-0.5 h-2.5 w-2.5 rounded-full
|
||||
ring-2 ring-surface"
|
||||
style="background: conic-gradient({{ slices|join(', ') }})"
|
||||
title="{{ row_on }}"></span>
|
||||
<span class="sr-only">{{ row_on }}</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-medium text-ink" title="{{ row_title }}">
|
||||
{{ row_title }}
|
||||
</p>
|
||||
|
||||
<p class="mt-0.5 text-xs text-ink-muted">
|
||||
<span class="sr-only">{{ row.transKey|trans }} · </span>
|
||||
{# An all-day row is floating — a wall date at
|
||||
midnight with no zone — so `false` keeps it in
|
||||
its own, and the reader's clock does not move it
|
||||
onto the day before. #}
|
||||
{{ row.startsAt|intl_date('EEEdMMM', row.event.isAllDay ? false : null) }}
|
||||
{%- if not row.event.isAllDay %} · {{ row.startsAt|date(clock.time) }}{% endif %}
|
||||
</p>
|
||||
|
||||
{% if row.source is not null %}
|
||||
<a href="{{ path('app_mail_message', { id: row.source.id }) }}"
|
||||
data-turbo-frame="_top"
|
||||
class="mt-1 inline-flex max-w-full items-center gap-1.5
|
||||
text-xs text-ink-faint hover:text-accent hover:underline"
|
||||
title="{{ row_from }}">
|
||||
<i class="fa-regular fa-envelope text-[10px] shrink-0" aria-hidden="true"></i>
|
||||
<span class="truncate">{{ row_from }}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
{# ── The radar ─────────────────────────────────────────────────
|
||||
One Stimulus scope over both sections, so the per-action
|
||||
dismiss token is minted once rather than once per card. #}
|
||||
{% if insights is not empty or undatedInsights is not empty %}
|
||||
<div data-controller="insight--radar"
|
||||
data-insight--radar-token-value="{{ csrf_token('insight-dismiss') }}"
|
||||
class="{{ rows is not empty ? 'mt-3 border-t border-line pt-4' : 'pt-1' }}">
|
||||
|
||||
{% if insights is not empty %}
|
||||
<section data-insight-section>
|
||||
<h3 class="text-[11px] font-semibold uppercase tracking-wider text-ink-faint">
|
||||
{{ 'insights.panel.heading'|trans }}
|
||||
</h3>
|
||||
|
||||
<ul class="mt-2 space-y-2" data-insight-list>
|
||||
{% for insight in insights %}
|
||||
{% set trackingUrl = insight.payload.trackingUrl|default(null) %}
|
||||
{% set status = insight.payload.status|default(null) %}
|
||||
{# Only the vocabulary the catalogue knows. An
|
||||
extractor's stray status must not print a raw
|
||||
translation key onto a card. #}
|
||||
{% set statusTone = {
|
||||
'announced': 'border-ink-faint/40 text-ink-muted',
|
||||
'in_transit': 'border-sky-500/40 text-sky-700 dark:text-sky-300',
|
||||
'out_for_delivery': 'border-amber-500/40 text-amber-700 dark:text-amber-300',
|
||||
'delivered': 'border-green-500/40 text-green-700 dark:text-green-300',
|
||||
}[status] ?? null %}
|
||||
|
||||
<li data-insight-card
|
||||
data-insight-id="{{ insight.id }}"
|
||||
data-insight-kind="{{ insight.kind.value }}"
|
||||
class="rounded-pane border border-line bg-surface p-3 shadow-sm">
|
||||
<div class="flex items-start gap-3">
|
||||
{% include 'insight/_kind_chip.html.twig' with { kind: insight.kind } only %}
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<p class="truncate text-sm font-semibold text-ink" title="{{ insight.title }}">
|
||||
{{ insight.title }}
|
||||
</p>
|
||||
<button type="button"
|
||||
data-action="click->insight--radar#dismiss"
|
||||
data-insight--radar-url-param="{{ path('app_insight_dismiss', { id: insight.id }) }}"
|
||||
aria-label="{{ 'insights.panel.dismiss'|trans }}"
|
||||
title="{{ 'insights.panel.dismiss'|trans }}"
|
||||
class="-mr-1.5 -mt-1 flex h-6 w-6 shrink-0 items-center justify-center rounded-full
|
||||
text-ink-faint hover:bg-hover hover:text-ink transition-colors">
|
||||
<i class="fa-solid fa-xmark text-xs" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="mt-0.5 text-xs text-ink-faint">
|
||||
{{ insight.happensAt|intl_date('EEEdMMM') }}
|
||||
{#- A parcel's ETA is a day, not an hour — its
|
||||
midnight is a placeholder, not a time. -#}
|
||||
{%- if insight.happensAt|date('His') != '000000' %} · {{ insight.happensAt|date(clock.time) }}{% endif %}
|
||||
{% if statusTone is not null %}
|
||||
<span data-insight-status="{{ status }}"
|
||||
class="ml-1 inline-flex items-center rounded-full border px-2 py-px
|
||||
text-[11px] leading-[1.35] font-medium {{ statusTone }}">
|
||||
{{ ('insights.status.' ~ status)|trans }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
{% if trackingUrl is not empty or insight.thread is not null %}
|
||||
<div class="mt-2 flex flex-wrap items-center gap-1.5">
|
||||
{% if trackingUrl is not empty %}
|
||||
<a href="{{ trackingUrl }}"
|
||||
target="_blank" rel="noopener noreferrer"
|
||||
data-insight-track
|
||||
class="inline-flex items-center gap-1.5 rounded-full bg-accent px-2.5 py-1
|
||||
text-xs font-medium text-accent-ink hover:bg-accent-strong transition-colors">
|
||||
<i class="fa-solid fa-location-arrow text-[10px]" aria-hidden="true"></i>
|
||||
{{ 'insights.panel.track'|trans }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if insight.thread is not null %}
|
||||
<a href="{{ path('app_mail_thread', { id: insight.thread.id }) }}"
|
||||
data-turbo-frame="_top"
|
||||
data-insight-open-mail
|
||||
class="inline-flex items-center gap-1.5 rounded-full border border-line px-2.5 py-1
|
||||
text-xs font-medium text-ink-muted hover:bg-hover hover:text-ink transition-colors">
|
||||
<i class="fa-regular fa-envelope text-[10px]" aria-hidden="true"></i>
|
||||
{{ 'insights.panel.open_mail'|trans }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if undatedInsights is not empty %}
|
||||
<section data-insight-section class="{{ insights is not empty ? 'mt-4' : '' }}">
|
||||
<h3 class="text-[11px] font-semibold uppercase tracking-wider text-ink-faint">
|
||||
{{ 'insights.panel.undated_heading'|trans }}
|
||||
</h3>
|
||||
|
||||
<ul class="mt-2 space-y-2" data-insight-list>
|
||||
{% for insight in undatedInsights %}
|
||||
{% set repo = insight.payload.repo|default(null) %}
|
||||
{% set number = insight.payload.number|default(null) %}
|
||||
{% set action = insight.payload.action|default(null) %}
|
||||
{% set url = insight.payload.url|default(null) %}
|
||||
|
||||
<li data-insight-card
|
||||
data-insight-id="{{ insight.id }}"
|
||||
data-insight-kind="{{ insight.kind.value }}"
|
||||
class="rounded-pane border border-line bg-surface p-3 shadow-sm">
|
||||
<div class="flex items-start gap-3">
|
||||
{% include 'insight/_kind_chip.html.twig' with { kind: insight.kind } only %}
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<p class="truncate text-sm font-semibold text-ink" title="{{ insight.title }}">
|
||||
{{ insight.title }}
|
||||
</p>
|
||||
<button type="button"
|
||||
data-action="click->insight--radar#dismiss"
|
||||
data-insight--radar-url-param="{{ path('app_insight_dismiss', { id: insight.id }) }}"
|
||||
aria-label="{{ 'insights.panel.dismiss'|trans }}"
|
||||
title="{{ 'insights.panel.dismiss'|trans }}"
|
||||
class="-mr-1.5 -mt-1 flex h-6 w-6 shrink-0 items-center justify-center rounded-full
|
||||
text-ink-faint hover:bg-hover hover:text-ink transition-colors">
|
||||
<i class="fa-solid fa-xmark text-xs" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{% if repo is not empty or number is not null or action is not empty %}
|
||||
<p class="mt-0.5 flex flex-wrap items-center gap-x-1.5 gap-y-1 text-xs text-ink-faint">
|
||||
{% if repo is not empty %}<span class="truncate">{{ repo }}</span>{% endif %}
|
||||
{% if number is not null %}<span class="shrink-0">#{{ number }}</span>{% endif %}
|
||||
{% if action is not empty %}
|
||||
<span data-insight-action="{{ action }}"
|
||||
class="inline-flex items-center rounded-full border border-ink-faint/40 px-2 py-px
|
||||
text-[11px] leading-[1.35] font-medium text-ink-muted">
|
||||
{{ action }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if url is not empty %}
|
||||
<div class="mt-2">
|
||||
{# The brand's own name rather than a
|
||||
catalogue key — "GitHub" is not a word
|
||||
any locale translates. #}
|
||||
<a href="{{ url }}"
|
||||
target="_blank" rel="noopener noreferrer"
|
||||
data-insight-open-url
|
||||
class="inline-flex items-center gap-1.5 rounded-full border border-line px-2.5 py-1
|
||||
text-xs font-medium text-ink-muted hover:bg-hover hover:text-ink transition-colors">
|
||||
<i class="fa-brands fa-github text-[11px]" aria-hidden="true"></i>
|
||||
GitHub
|
||||
<i class="fa-solid fa-arrow-up-right-from-square text-[9px]" aria-hidden="true"></i>
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</turbo-frame>
|
||||
@@ -0,0 +1,33 @@
|
||||
{#
|
||||
insight/_kind_chip.html.twig
|
||||
─────────────────────────────────────────────────────────────────────────────
|
||||
The kind's icon on its coloured tile, shared by the radar panel and the
|
||||
reading-pane strip so a parcel is the same amber square everywhere it appears.
|
||||
|
||||
Accepts:
|
||||
kind InsightKind
|
||||
size optional Tailwind sizing classes, default 'h-9 w-9'
|
||||
|
||||
One colour per kind, filled at 600 weight with white iconography, and
|
||||
deliberately NOT the theme tokens: a 600-weight fill reads identically on both
|
||||
themes, where a tokened tint would drift with whichever surface it lands on.
|
||||
Both GitHub kinds share slate on purpose — issue and pull request are told
|
||||
apart by their icon, and two greys nobody can name apart would only pretend to
|
||||
say more. Full class names, spelled out per kind, because Tailwind's scanner
|
||||
reads this file as text and a composed 'bg-' ~ colour would compile to nothing.
|
||||
|
||||
The kind's name rides along for anyone who cannot see the tile, so a card
|
||||
still says "Parcel" rather than leaving the colour to do all the work.
|
||||
#}
|
||||
{% set tone = {
|
||||
'parcel': 'bg-amber-600',
|
||||
'flight': 'bg-sky-600',
|
||||
'ticket': 'bg-violet-600',
|
||||
'github-issue': 'bg-slate-600',
|
||||
'github-pr': 'bg-slate-600',
|
||||
}[kind.value] ?? 'bg-slate-600' %}
|
||||
|
||||
<span class="flex {{ size|default('h-9 w-9') }} shrink-0 items-center justify-center rounded-lg {{ tone }} text-white shadow-sm">
|
||||
<i class="{{ kind.icon }} text-sm" aria-hidden="true"></i>
|
||||
<span class="sr-only">{{ kind.transKey|trans }}</span>
|
||||
</span>
|
||||
@@ -0,0 +1,109 @@
|
||||
{#
|
||||
mail/_insight_strip.html.twig
|
||||
─────────────────────────────────────────────────────────────────────────────
|
||||
The insights a conversation carries, as a compact strip above the message
|
||||
body: icon tile, title, when, and the one action worth a button — tracking the
|
||||
parcel, opening the thing on GitHub. The mail below states these facts in
|
||||
prose; the strip states them in a glance, which is the whole trade of an
|
||||
insight (see MailInsight).
|
||||
|
||||
Included from both reading panes — _message_content and _thread_content — with
|
||||
`only`, so it names its whole contract:
|
||||
|
||||
Accepts:
|
||||
insights list<MailInsight> MailInsightRepository::forThread(), and possibly
|
||||
containing DISMISSED rows on purpose: dismissal hides a card from
|
||||
the radar panel, not from the mail that plainly states the fact —
|
||||
the repository documents that choice.
|
||||
|
||||
Renders nothing when the thread carries nothing, which is almost every thread:
|
||||
the include must stay free for the common case, and an empty "insights" box
|
||||
over an ordinary mail would claim the extractor looked and found this mail
|
||||
wanting.
|
||||
|
||||
No dismiss button here, deliberately. Dismissal means "stop showing this in
|
||||
the panel", and this is not the panel — a card that vanished from the mail
|
||||
that produced it would make the extraction unfalsifiable, the same reasoning
|
||||
the Happening Soon rows give for their provenance link.
|
||||
#}
|
||||
|
||||
{% set insights = insights|default([]) %}
|
||||
|
||||
{% if insights is not empty %}
|
||||
<div class="mb-4" data-insight-strip>
|
||||
<p class="mb-1.5 text-[11px] font-semibold uppercase tracking-wider text-ink-faint">
|
||||
{{ 'insights.card.heading'|trans }}
|
||||
</p>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{% for insight in insights %}
|
||||
{% set trackingUrl = insight.payload.trackingUrl|default(null) %}
|
||||
{% set externalUrl = insight.payload.url|default(null) %}
|
||||
{% set status = insight.payload.status|default(null) %}
|
||||
{% set statusTone = {
|
||||
'announced': 'border-ink-faint/40 text-ink-muted',
|
||||
'in_transit': 'border-sky-500/40 text-sky-700 dark:text-sky-300',
|
||||
'out_for_delivery': 'border-amber-500/40 text-amber-700 dark:text-amber-300',
|
||||
'delivered': 'border-green-500/40 text-green-700 dark:text-green-300',
|
||||
}[status] ?? null %}
|
||||
|
||||
<div data-insight-card
|
||||
data-insight-kind="{{ insight.kind.value }}"
|
||||
class="flex min-w-0 max-w-full items-center gap-2.5 rounded-pane border border-line
|
||||
bg-surface py-2 pl-2 pr-3 shadow-sm">
|
||||
|
||||
{% include 'insight/_kind_chip.html.twig' with { kind: insight.kind, size: 'h-8 w-8' } only %}
|
||||
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-semibold text-ink" title="{{ insight.title }}">
|
||||
{{ insight.title }}
|
||||
</p>
|
||||
<p class="truncate text-xs text-ink-faint">
|
||||
{% if insight.happensAt is not null %}
|
||||
{{ insight.happensAt|intl_date('EEEdMMM') }}
|
||||
{#- A parcel's ETA is a day, not an hour — its midnight
|
||||
is a placeholder, not a time. -#}
|
||||
{%- if insight.happensAt|date('His') != '000000' %} · {{ insight.happensAt|date(clock.time) }}{% endif %}
|
||||
{% elseif insight.payload.repo|default(null) is not empty %}
|
||||
{{ insight.payload.repo }}{% if insight.payload.number|default(null) is not null %} · #{{ insight.payload.number }}{% endif %}
|
||||
{% else %}
|
||||
{{ insight.kind.transKey|trans }}
|
||||
{% endif %}
|
||||
{% if statusTone is not null %}
|
||||
<span data-insight-status="{{ status }}"
|
||||
class="ml-1 inline-flex items-center rounded-full border px-2 py-px
|
||||
text-[11px] leading-[1.35] font-medium {{ statusTone }}">
|
||||
{{ ('insights.status.' ~ status)|trans }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{# One primary action per card: the tracking page where there
|
||||
is one, else the thing on GitHub. Never both — a compact
|
||||
card with two buttons is a wide card with none pressed. #}
|
||||
{% if trackingUrl is not empty %}
|
||||
<a href="{{ trackingUrl }}"
|
||||
target="_blank" rel="noopener noreferrer"
|
||||
data-insight-track
|
||||
class="ml-1 inline-flex shrink-0 items-center gap-1.5 rounded-full bg-accent px-2.5 py-1
|
||||
text-xs font-medium text-accent-ink hover:bg-accent-strong transition-colors">
|
||||
<i class="fa-solid fa-location-arrow text-[10px]" aria-hidden="true"></i>
|
||||
{{ 'insights.panel.track'|trans }}
|
||||
</a>
|
||||
{% elseif externalUrl is not empty %}
|
||||
<a href="{{ externalUrl }}"
|
||||
target="_blank" rel="noopener noreferrer"
|
||||
data-insight-open-url
|
||||
class="ml-1 inline-flex shrink-0 items-center gap-1.5 rounded-full border border-line px-2.5 py-1
|
||||
text-xs font-medium text-ink-muted hover:bg-hover hover:text-ink transition-colors">
|
||||
<i class="fa-brands fa-github text-[11px]" aria-hidden="true"></i>
|
||||
GitHub
|
||||
<i class="fa-solid fa-arrow-up-right-from-square text-[9px]" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -47,6 +47,9 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{# What plMail read out of this conversation, before the conversation. #}
|
||||
{% include 'mail/_insight_strip.html.twig' with { insights: insights|default([]) } only %}
|
||||
|
||||
{# Attachments above the body (H-13) — see _thread_message for why. #}
|
||||
{% if message.hasAttachments %}
|
||||
<div class="flex flex-wrap items-center gap-2 mb-4">
|
||||
|
||||
@@ -68,6 +68,9 @@ self-contained: no {% extends %}, no block dependencies.
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# What plMail read out of this conversation, before the conversation. #}
|
||||
{% include 'mail/_insight_strip.html.twig' with { insights: insights|default([]) } only %}
|
||||
|
||||
{# `messages` is the controller's conversation-ordered list; the raw
|
||||
association is the fallback for any include that has not been given
|
||||
it (its receivedAt-only order is why the list is passed at all). #}
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Controller\Insight;
|
||||
|
||||
use App\Domain\Enum\Insight\InsightKind;
|
||||
use App\Domain\Enum\Mail\MessageCategory;
|
||||
use App\Domain\Enum\Mail\ThreadingMethod;
|
||||
use App\Entity\Insight\MailInsight;
|
||||
use App\Entity\Mail\Account;
|
||||
use App\Entity\Mail\Message;
|
||||
use App\Entity\Mail\MessageThread;
|
||||
use App\Entity\User\User;
|
||||
use DateTimeImmutable;
|
||||
use DateTimeZone;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
|
||||
/**
|
||||
* The radar: mail insights on the Happening Soon panel, and the strip above a
|
||||
* conversation.
|
||||
*
|
||||
* Pinned as REQUESTS rather than against the repository, for the reason
|
||||
* HappeningSoonPanelTest gives: what matters is the markup a user's browser
|
||||
* receives — a card per insight, a dismiss that writes dismissedAt and only
|
||||
* dismissedAt, and a strip on the mail itself. Everything is selected by the
|
||||
* data attributes the templates stamp (`data-insight-card`,
|
||||
* `data-insight-kind`, `data-insight-strip`), never by translated strings: the
|
||||
* catalogues belong to another workstream, and a test that greps for "Track"
|
||||
* would fail in German for reasons no assertion states.
|
||||
*
|
||||
* Fixtures follow HappeningSoonPanelTest exactly — a fresh user inside a
|
||||
* transaction that is never committed — rather than borrowing the seeded
|
||||
* e2e-admin, whose mailbox this suite would otherwise have to leave exactly as
|
||||
* it found it.
|
||||
*/
|
||||
final class RadarPanelTest extends WebTestCase
|
||||
{
|
||||
private EntityManagerInterface $em;
|
||||
private Connection $connection;
|
||||
private User $user;
|
||||
private Account $account;
|
||||
private DateTimeImmutable $now;
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if (true === isset($this->connection) && true === $this->connection->isTransactionActive()) {
|
||||
$this->connection->rollBack();
|
||||
}
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testTheRadarDrawsACardPerInsight(): void
|
||||
{
|
||||
$client = $this->signIn();
|
||||
|
||||
$this->parcel();
|
||||
$this->pullRequest();
|
||||
|
||||
$crawler = $client->request('GET', '/calendar/soon');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertCount(2, $crawler->filter('[data-insight-card]'));
|
||||
|
||||
// The dated card: kind, status chip and the tracking link with the
|
||||
// attributes that make it safe to open.
|
||||
$parcel = $crawler->filter('[data-insight-card][data-insight-kind="parcel"]');
|
||||
self::assertCount(1, $parcel);
|
||||
self::assertCount(1, $parcel->filter('[data-insight-status="in_transit"]'));
|
||||
|
||||
$track = $parcel->filter('a[data-insight-track]');
|
||||
self::assertCount(1, $track);
|
||||
self::assertSame('https://tracking.example.test/PKG-1', $track->attr('href'));
|
||||
self::assertSame('_blank', $track->attr('target'));
|
||||
self::assertStringContainsString('noopener', (string) $track->attr('rel'));
|
||||
|
||||
// The undated card under its own section: repo, number, action and the
|
||||
// external link.
|
||||
$github = $crawler->filter('[data-insight-card][data-insight-kind="github-pr"]');
|
||||
self::assertCount(1, $github);
|
||||
self::assertCount(1, $github->filter('[data-insight-action="review_requested"]'));
|
||||
self::assertStringContainsString('plmail/pl_mail', $github->text());
|
||||
self::assertStringContainsString('#42', $github->text());
|
||||
self::assertSame(
|
||||
'https://github.example.test/plmail/pl_mail/pull/42',
|
||||
$github->filter('a[data-insight-open-url]')->attr('href'),
|
||||
);
|
||||
|
||||
// Dated and undated live under separate subheadings.
|
||||
self::assertCount(2, $crawler->filter('[data-insight-section]'));
|
||||
}
|
||||
|
||||
/** An empty radar adds nothing — no section, no heading, no empty state of its own. */
|
||||
public function testAnEmptyRadarRendersNothingExtra(): void
|
||||
{
|
||||
$client = $this->signIn();
|
||||
|
||||
$crawler = $client->request('GET', '/calendar/soon');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertCount(0, $crawler->filter('[data-insight-card]'));
|
||||
self::assertCount(0, $crawler->filter('[data-insight-section]'));
|
||||
}
|
||||
|
||||
public function testDismissWithATokenSetsDismissedAtAndTheCardIsGone(): void
|
||||
{
|
||||
$client = $this->signIn();
|
||||
$insight = $this->parcel();
|
||||
|
||||
$client->request('POST', "/insights/{$insight->id}/dismiss", [
|
||||
'_token' => $this->token($client),
|
||||
]);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertSame(
|
||||
['ok' => true],
|
||||
json_decode((string) $client->getResponse()->getContent(), true),
|
||||
);
|
||||
|
||||
self::assertNotNull($this->reread($insight)->dismissedAt, 'the dismissal was not written');
|
||||
|
||||
$crawler = $client->request('GET', '/calendar/soon');
|
||||
self::assertCount(0, $crawler->filter('[data-insight-card]'), 'a dismissed card came back');
|
||||
}
|
||||
|
||||
public function testATokenlessDismissWritesNothing(): void
|
||||
{
|
||||
$client = $this->signIn();
|
||||
$insight = $this->parcel();
|
||||
|
||||
$client->request('POST', "/insights/{$insight->id}/dismiss");
|
||||
|
||||
self::assertSame(403, $client->getResponse()->getStatusCode());
|
||||
self::assertNull($this->reread($insight)->dismissedAt, 'a tokenless POST dismissed the card');
|
||||
}
|
||||
|
||||
/** A valid token proves the page, not the ownership — a stranger's insight stays. */
|
||||
public function testAForeignInsightCannotBeDismissed(): void
|
||||
{
|
||||
$client = $this->signIn();
|
||||
|
||||
// The signed-in user needs a card of their own so the panel renders
|
||||
// the radar — the token is minted there.
|
||||
$this->parcel();
|
||||
|
||||
$stranger = $this->seedUser('radar-stranger-');
|
||||
$mailbox = $this->seedAccount($stranger);
|
||||
$foreign = $this->insight($mailbox, InsightKind::Parcel, 'Not your parcel', $this->now->modify('+3 days'));
|
||||
|
||||
$client->request('POST', "/insights/{$foreign->id}/dismiss", [
|
||||
'_token' => $this->token($client),
|
||||
]);
|
||||
|
||||
self::assertSame(403, $client->getResponse()->getStatusCode());
|
||||
self::assertNull($this->reread($foreign)->dismissedAt, "a stranger's POST dismissed the card");
|
||||
}
|
||||
|
||||
public function testTheThreadViewShowsTheInsightStrip(): void
|
||||
{
|
||||
$client = $this->signIn();
|
||||
|
||||
$thread = $this->thread('Your order has shipped');
|
||||
$this->parcel($thread);
|
||||
|
||||
$crawler = $client->request('GET', "/mail/thread/{$thread->id}");
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertCount(1, $crawler->filter('[data-insight-strip]'));
|
||||
|
||||
$card = $crawler->filter('[data-insight-strip] [data-insight-card][data-insight-kind="parcel"]');
|
||||
self::assertCount(1, $card);
|
||||
self::assertCount(1, $card->filter('a[data-insight-track]'));
|
||||
}
|
||||
|
||||
/** And a thread nobody extracted anything from carries no strip at all. */
|
||||
public function testAThreadWithoutInsightsCarriesNoStrip(): void
|
||||
{
|
||||
$client = $this->signIn();
|
||||
|
||||
$thread = $this->thread('Just a mail');
|
||||
|
||||
$crawler = $client->request('GET', "/mail/thread/{$thread->id}");
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertCount(0, $crawler->filter('[data-insight-strip]'));
|
||||
}
|
||||
|
||||
// ── Fixtures ──────────────────────────────────────────────────────────
|
||||
|
||||
private function parcel(?MessageThread $thread = null): MailInsight
|
||||
{
|
||||
return $this->insight(
|
||||
$this->account,
|
||||
InsightKind::Parcel,
|
||||
'DHL parcel from Zalando',
|
||||
$this->now->modify('+2 days')->setTime(9, 0),
|
||||
[
|
||||
'status' => 'in_transit',
|
||||
'trackingUrl' => 'https://tracking.example.test/PKG-1',
|
||||
],
|
||||
$thread,
|
||||
);
|
||||
}
|
||||
|
||||
private function pullRequest(): MailInsight
|
||||
{
|
||||
return $this->insight(
|
||||
$this->account,
|
||||
InsightKind::GithubPullRequest,
|
||||
'Make the radar sweep',
|
||||
null,
|
||||
[
|
||||
'repo' => 'plmail/pl_mail',
|
||||
'number' => 42,
|
||||
'action' => 'review_requested',
|
||||
'url' => 'https://github.example.test/plmail/pl_mail/pull/42',
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $payload */
|
||||
private function insight(
|
||||
Account $account,
|
||||
InsightKind $kind,
|
||||
string $title,
|
||||
?DateTimeImmutable $happensAt,
|
||||
array $payload = [],
|
||||
?MessageThread $thread = null,
|
||||
): MailInsight {
|
||||
$insight = new MailInsight();
|
||||
$insight->account = $account;
|
||||
$insight->kind = $kind;
|
||||
$insight->title = $title;
|
||||
$insight->payload = $payload;
|
||||
$insight->happensAt = $happensAt;
|
||||
$insight->extractor = 'radar-test';
|
||||
$insight->dedupeKey = uniqid('radar-', true);
|
||||
$insight->thread = $thread;
|
||||
|
||||
$this->em->persist($insight);
|
||||
$this->em->flush();
|
||||
|
||||
return $insight;
|
||||
}
|
||||
|
||||
/** The shape SeedsMarkerFixtures::thread() seeds, minus the label the radar does not need. */
|
||||
private function thread(string $subject): MessageThread
|
||||
{
|
||||
$thread = new MessageThread();
|
||||
$thread->account = $this->account;
|
||||
$thread->subject = $subject;
|
||||
$thread->normalizedSubject = mb_strtolower($subject);
|
||||
$thread->threadingMethod = ThreadingMethod::SubjectFallback;
|
||||
$thread->lastMessageAt = $this->now;
|
||||
$thread->category = MessageCategory::Primary;
|
||||
$thread->messageCount = 1;
|
||||
$thread->unreadCount = 0;
|
||||
$this->em->persist($thread);
|
||||
|
||||
$message = new Message();
|
||||
$message->account = $this->account;
|
||||
$message->thread = $thread;
|
||||
$message->messageId = uniqid('radar-', true) . '@example.test';
|
||||
$message->subject = $subject;
|
||||
$message->fromAddress = 'shop@example.test';
|
||||
$message->receivedAt = $this->now->modify('-1 hour');
|
||||
$message->sentAt = $message->receivedAt;
|
||||
$message->seenAt = $message->receivedAt;
|
||||
$message->flags = [];
|
||||
$message->hasAttachments = false;
|
||||
$thread->addMessage($message);
|
||||
$this->em->persist($message);
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
return $thread;
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-action token, read off the rendered radar — which is also the
|
||||
* only place the real button gets it from (AppearancePaneStateTest reads
|
||||
* its token the same way).
|
||||
*/
|
||||
private function token(KernelBrowser $client): string
|
||||
{
|
||||
$crawler = $client->request('GET', '/calendar/soon');
|
||||
|
||||
return (string) $crawler
|
||||
->filter('[data-controller="insight--radar"]')
|
||||
->attr('data-insight--radar-token-value');
|
||||
}
|
||||
|
||||
/** The row as the database now has it, not as the last request left it. */
|
||||
private function reread(MailInsight $insight): MailInsight
|
||||
{
|
||||
$id = $insight->id;
|
||||
|
||||
$this->em->clear();
|
||||
|
||||
$found = $this->em->find(MailInsight::class, $id);
|
||||
|
||||
self::assertInstanceOf(MailInsight::class, $found);
|
||||
|
||||
return $found;
|
||||
}
|
||||
|
||||
private function signIn(): KernelBrowser
|
||||
{
|
||||
$client = static::createClient();
|
||||
$client->disableReboot();
|
||||
|
||||
$container = static::getContainer();
|
||||
$this->em = $container->get(EntityManagerInterface::class);
|
||||
$this->connection = $container->get(Connection::class);
|
||||
$this->now = new DateTimeImmutable('now', new DateTimeZone('UTC'));
|
||||
|
||||
// Never committed, so the suite leaves nothing behind and can be run
|
||||
// repeatedly against the same database.
|
||||
$this->connection->beginTransaction();
|
||||
|
||||
$this->user = $this->seedUser('radar-panel-');
|
||||
$this->account = $this->seedAccount($this->user);
|
||||
|
||||
$client->loginUser($this->user);
|
||||
|
||||
return $client;
|
||||
}
|
||||
|
||||
private function seedUser(string $prefix): User
|
||||
{
|
||||
$user = new User();
|
||||
$user->email = $prefix . uniqid('', true) . '@example.test';
|
||||
$user->nameFirst = 'Radar';
|
||||
$user->nameLast = 'Panel';
|
||||
$user->roles = ['ROLE_USER'];
|
||||
$user->password = '$2y$04$abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOP';
|
||||
|
||||
$this->em->persist($user);
|
||||
$this->em->flush();
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function seedAccount(User $user): Account
|
||||
{
|
||||
$account = new Account();
|
||||
$account->usr = $user;
|
||||
$account->name = 'Radar fixture';
|
||||
$account->email = uniqid('radar-', true) . '@example.test';
|
||||
$account->username = uniqid('radar-', true);
|
||||
$account->imapHost = 'localhost';
|
||||
$account->imapPort = 993;
|
||||
$account->imapEncryption = 'ssl';
|
||||
$account->smtpHost = 'localhost';
|
||||
$account->smtpPort = 587;
|
||||
$account->smtpEncryption = 'starttls';
|
||||
$account->password = 'x';
|
||||
$account->authType = 'password';
|
||||
$account->isActive = true;
|
||||
|
||||
$this->em->persist($account);
|
||||
$this->em->flush();
|
||||
|
||||
return $account;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user