Put the announcement behind the header icon too, from one source

A message worth showing was only on the dashboard, which means somebody
who works in Files and Clients all day never meets it. It now also sits
behind an icon next to the notification bell, and that is on every page.

**One shared prop, not two.** "The same message in both places" is the
requirement, and two props would have drifted the first time anybody
edited one — so the hook moved out of DashboardController into
HandleInertiaRequests, and the dashboard reads the same shared value the
header does. The band and the dropdown also share the component that
renders the words, for the same reason: the reliable way to keep two
renderings identical is not to have two.

Renamed with it. ResolvingDashboardCallout was accurate for about an hour
and became a lie the moment it appeared somewhere else; it is
ResolvingAnnouncement now, and the prop is `announcement`. Free to rename
because nothing has shipped yet — the only other reference was
cloud-modules', by string, updated alongside.

The icon follows UpdateAvailableIcon beside it: absent entirely when there
is nothing to say rather than a dead control, and a plain dot instead of a
count, because there is only ever one of these and a "1" would invite
somebody to look for the second.

Two tests worth naming. One asserts the message reaches a page that is not
the dashboard, which is the whole point of the addition. The other asserts
a client is shown nothing even from a listener that sets it
unconditionally — a client's header carries the bell too, and staff
messages must not reach it however careless the listener.
This commit is contained in:
ignacionelson
2026-09-08 02:17:56 -03:00
parent 334b11d562
commit d7d7acce85
25 changed files with 259 additions and 119 deletions
@@ -24,6 +24,7 @@ use App\Modules\Platform\Settings\Settings;
use App\Modules\Platform\Updates\LatestReleaseInfo;
use App\Modules\Platform\Updates\RunningCodeState;
use Illuminate\Foundation\Inspiring;
use App\Modules\Platform\Announcements\Events\ResolvingAnnouncement;
use App\Modules\Platform\Navigation\Events\ResolvingNavigationLinks;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Event;
@@ -93,6 +94,12 @@ class HandleInertiaRequests extends Middleware
// See ResolvingNavigationLinks for why core never learns what
// is in it.
'extra_nav_links' => $this->extraNavLinks($request),
// Shared rather than a dashboard prop, because it is shown in
// two places — the band on the dashboard and the icon beside
// the notification bell everywhere else — and "the same
// message" is the requirement. Two props would drift the day
// somebody edited one.
'announcement' => $this->announcement($request),
// Shared rather than passed by each page: the sign-in buttons,
// the registration form and the Connected accounts nav entry
// all need the same list, and a nav entry to a screen with
@@ -331,4 +338,22 @@ class HandleInertiaRequests extends Middleware
return $event->links;
}
/**
* @return array{title: string, body: string, action_label: string|null, action_url: string|null, tone: string}|null
*/
private function announcement(Request $request): ?array
{
$user = $request->user();
if ($user === null) {
return null;
}
$event = new ResolvingAnnouncement(isStaff: $user->isStaff());
Event::dispatch($event);
return $event->announcement;
}
}
@@ -13,7 +13,6 @@ use App\Modules\Audit\ActivityLogScope;
use App\Modules\Audit\ActivityPresenter;
use App\Modules\Audit\DashboardWidgetPreferences;
use Illuminate\Support\Facades\Event;
use App\Modules\Platform\Announcements\Events\ResolvingDashboardCallout;
use App\Modules\Clients\ClientStorageUsage;
use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Files\Delivery\FileDelivery;
@@ -86,12 +85,6 @@ class DashboardController extends Controller
$prefs = $this->widgetPrefs;
return Inertia::render('dashboard', [
// Above the grid rather than in it — see
// ResolvingDashboardCallout for why a message that matters
// must not be something each viewer can drag under a fold.
// Null with nothing listening, which is every community
// installation.
'callout' => $this->callout($user),
'counters' => $canCounters && $prefs->isEnabled($user, 'counters') ? $this->counters() : null,
'transfers' => $canStatistics && $prefs->isEnabled($user, 'transfers') ? $this->transferSeries($from, $to, $timezone) : null,
'transfers_range' => $canStatistics && $prefs->isEnabled($user, 'transfers')
@@ -550,16 +543,4 @@ class DashboardController extends Controller
])->all(),
]);
}
/**
* @return array{title: string, body: string, action_label: string|null, action_url: string|null, tone: string}|null
*/
private function callout(User $user): ?array
{
$event = new ResolvingDashboardCallout(isStaff: $user->isStaff());
Event::dispatch($event);
return $event->callout;
}
}
@@ -5,13 +5,18 @@ declare(strict_types=1);
namespace App\Modules\Platform\Announcements\Events;
/**
* A single message a package wants shown across the top of the dashboard.
* A single message a package wants put in front of staff.
*
* Shown twice, from one source: a band across the top of the dashboard,
* and an icon beside the notification bell that opens the same words on
* every other page. One event rather than two because "the same message"
* is the requirement — two props would drift the day somebody edits one.
*
* Not a widget, on purpose. The widget grid is a closed list of keys that
* dashboard.tsx renders one by one, and each viewer arranges it — so a
* message that matters would sit wherever somebody happened to drag it,
* or under a fold, or switched off. A callout is one band above the grid:
* seen, and not competing with the columns for space.
* or under a fold, or switched off. A band above the grid is seen without
* competing with the columns for space.
*
* **Core knows nothing about what it says.** Title, body, the label on the
* button and where the button goes all come from the listener. The first
@@ -21,15 +26,15 @@ namespace App\Modules\Platform\Announcements\Events;
*
* One at a time, deliberately. A dashboard that can accumulate banners
* accumulates them, and the second one is what teaches people to skip the
* first. A listener that finds `$callout` already set should leave it
* alone rather than overwrite it.
* first. A listener that finds one already set should leave it alone
* rather than overwrite it.
*/
class ResolvingDashboardCallout
class ResolvingAnnouncement
{
/**
* @var array{title: string, body: string, action_label: string|null, action_url: string|null, tone: string}|null
*/
public ?array $callout = null;
public ?array $announcement = null;
public function __construct(
/** Whether the viewer is a staff account. */
@@ -44,11 +49,11 @@ class ResolvingDashboardCallout
*/
public function show(string $title, string $body, ?string $actionLabel = null, ?string $actionUrl = null, string $tone = 'info'): void
{
if ($this->callout !== null) {
if ($this->announcement !== null) {
return;
}
$this->callout = [
$this->announcement = [
'title' => $title,
'body' => $body,
'action_label' => $actionLabel,
+3 -1
View File
@@ -2015,5 +2015,7 @@
"Make this file public": "Fes públic aquest fitxer",
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Aquest lloc encara no té cap pàgina pública configurada, així que no es veurà res fins que un administrador en configuri una.",
"Show ProjectSend news on the dashboard": "Mostra les novetats de ProjectSend al tauler",
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Recupera els anuncis del projecte des de projectsend.org un cop al dia per a la targeta del tauler. Si ho desactives, aquesta instal·lació deixa de contactar amb projectsend.org per a novetats."
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Recupera els anuncis del projecte des de projectsend.org un cop al dia per a la targeta del tauler. Si ho desactives, aquesta instal·lació deixa de contactar amb projectsend.org per a novetats.",
"Announcement": "Avís",
"More": "Més"
}
+3 -1
View File
@@ -2015,5 +2015,7 @@
"Make this file public": "Zveřejnit tento soubor",
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Tento web zatím nemá nastavenou veřejnou stránku, takže nebude nic vidět, dokud ji administrátor nenastaví.",
"Show ProjectSend news on the dashboard": "Zobrazovat novinky ProjectSendu v přehledu",
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Jednou denně načte oznámení projektu z projectsend.org pro kartu v přehledu. Když to vypnete, tato instalace se kvůli novinkám na projectsend.org už vůbec nepřipojí."
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Jednou denně načte oznámení projektu z projectsend.org pro kartu v přehledu. Když to vypnete, tato instalace se kvůli novinkám na projectsend.org už vůbec nepřipojí.",
"Announcement": "Oznámení",
"More": "Další"
}
+3 -1
View File
@@ -2015,5 +2015,7 @@
"Make this file public": "Diese Datei öffentlich machen",
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Für diese Website ist noch keine öffentliche Seite eingerichtet, daher ist nichts sichtbar, bis ein Administrator eine einrichtet.",
"Show ProjectSend news on the dashboard": "ProjectSend-Neuigkeiten in der Übersicht anzeigen",
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Ruft einmal täglich Projektankündigungen von projectsend.org für die Karte in der Übersicht ab. Ausgeschaltet nimmt diese Installation für Neuigkeiten überhaupt keine Verbindung zu projectsend.org mehr auf."
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Ruft einmal täglich Projektankündigungen von projectsend.org für die Karte in der Übersicht ab. Ausgeschaltet nimmt diese Installation für Neuigkeiten überhaupt keine Verbindung zu projectsend.org mehr auf.",
"Announcement": "Ankündigung",
"More": "Mehr"
}
+3 -1
View File
@@ -2015,5 +2015,7 @@
"Make this file public": "Hacer público este archivo",
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Este sitio todavía no tiene una página pública configurada, así que no se verá nada hasta que un administrador la configure.",
"Show ProjectSend news on the dashboard": "Mostrar las noticias de ProjectSend en el panel de control",
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Trae los anuncios del proyecto desde projectsend.org una vez al día para la tarjeta del panel. Si lo desactivas, esta instalación deja de contactar a projectsend.org por noticias."
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Trae los anuncios del proyecto desde projectsend.org una vez al día para la tarjeta del panel. Si lo desactivas, esta instalación deja de contactar a projectsend.org por noticias.",
"Announcement": "Aviso",
"More": "Más"
}
+3 -1
View File
@@ -2015,5 +2015,7 @@
"Make this file public": "Rendre ce fichier public",
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Ce site n'a pas encore de page publique configurée, donc rien ne sera visible tant qu'un administrateur n'en aura pas configuré une.",
"Show ProjectSend news on the dashboard": "Afficher les actualités ProjectSend sur le tableau de bord",
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Récupère une fois par jour les annonces du projet depuis projectsend.org pour la carte du tableau de bord. Désactivé, cette installation ne contacte plus du tout projectsend.org pour les actualités."
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Récupère une fois par jour les annonces du projet depuis projectsend.org pour la carte du tableau de bord. Désactivé, cette installation ne contacte plus du tout projectsend.org pour les actualités.",
"Announcement": "Annonce",
"More": "Plus"
}
+3 -1
View File
@@ -2015,5 +2015,7 @@
"Make this file public": "Jadikan berkas ini publik",
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Situs ini belum punya halaman publik, jadi tidak ada yang terlihat sampai administrator menyiapkannya.",
"Show ProjectSend news on the dashboard": "Tampilkan kabar terbaru ProjectSend di dasbor",
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Mengambil pengumuman proyek dari projectsend.org sekali sehari untuk kartu di dasbor. Jika dimatikan, instalasi ini sama sekali tidak lagi menghubungi projectsend.org untuk kabar terbaru."
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Mengambil pengumuman proyek dari projectsend.org sekali sehari untuk kartu di dasbor. Jika dimatikan, instalasi ini sama sekali tidak lagi menghubungi projectsend.org untuk kabar terbaru.",
"Announcement": "Pengumuman",
"More": "Lainnya"
}
+3 -1
View File
@@ -2015,5 +2015,7 @@
"Make this file public": "Rendi pubblico questo file",
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Questo sito non ha ancora una pagina pubblica, quindi non sarà visibile nulla finché un amministratore non ne configura una.",
"Show ProjectSend news on the dashboard": "Mostra le novità di ProjectSend nel pannello",
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Recupera gli annunci del progetto da projectsend.org una volta al giorno per la scheda del pannello. Disattivandolo, questa installazione smette del tutto di contattare projectsend.org per le novità."
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Recupera gli annunci del progetto da projectsend.org una volta al giorno per la scheda del pannello. Disattivandolo, questa installazione smette del tutto di contattare projectsend.org per le novità.",
"Announcement": "Avviso",
"More": "Altro"
}
+3 -1
View File
@@ -2015,5 +2015,7 @@
"Make this file public": "このファイルを公開する",
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "このサイトにはまだ公開ページが設定されていないため、管理者が設定するまで何も表示されません。",
"Show ProjectSend news on the dashboard": "ダッシュボードに ProjectSend のお知らせを表示する",
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "ダッシュボードのカード用に、プロジェクトのお知らせを projectsend.org から1日1回取得します。オフにすると、このインストールはお知らせのために projectsend.org へ接続しなくなります。"
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "ダッシュボードのカード用に、プロジェクトのお知らせを projectsend.org から1日1回取得します。オフにすると、このインストールはお知らせのために projectsend.org へ接続しなくなります。",
"Announcement": "お知らせ",
"More": "その他"
}
+3 -1
View File
@@ -2015,5 +2015,7 @@
"Make this file public": "Dit bestand openbaar maken",
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Deze site heeft nog geen openbare pagina, dus er is niets zichtbaar totdat een beheerder er een instelt.",
"Show ProjectSend news on the dashboard": "ProjectSend-nieuws op het overzicht tonen",
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Haalt eens per dag projectaankondigingen op van projectsend.org voor de kaart op het overzicht. Uitgeschakeld neemt deze installatie voor nieuws helemaal geen contact meer op met projectsend.org."
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Haalt eens per dag projectaankondigingen op van projectsend.org voor de kaart op het overzicht. Uitgeschakeld neemt deze installatie voor nieuws helemaal geen contact meer op met projectsend.org.",
"Announcement": "Mededeling",
"More": "Meer"
}
+3 -1
View File
@@ -2015,5 +2015,7 @@
"Make this file public": "Ustaw ten plik jako publiczny",
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Ta witryna nie ma jeszcze skonfigurowanej strony publicznej, więc nic nie będzie widoczne, dopóki administrator jej nie ustawi.",
"Show ProjectSend news on the dashboard": "Pokazuj aktualności ProjectSend na pulpicie",
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Raz dziennie pobiera ogłoszenia projektu z projectsend.org na kartę pulpitu. Po wyłączeniu ta instalacja w ogóle przestaje łączyć się z projectsend.org po aktualności."
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Raz dziennie pobiera ogłoszenia projektu z projectsend.org na kartę pulpitu. Po wyłączeniu ta instalacja w ogóle przestaje łączyć się z projectsend.org po aktualności.",
"Announcement": "Ogłoszenie",
"More": "Więcej"
}
+3 -1
View File
@@ -2015,5 +2015,7 @@
"Make this file public": "Tornar este arquivo público",
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Este site ainda não tem uma página pública configurada, então nada ficará visível até que um administrador configure uma.",
"Show ProjectSend news on the dashboard": "Mostrar novidades do ProjectSend no painel",
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Busca os anúncios do projeto em projectsend.org uma vez por dia para o cartão do painel. Se você desativar, esta instalação para de contatar o projectsend.org por novidades."
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Busca os anúncios do projeto em projectsend.org uma vez por dia para o cartão do painel. Se você desativar, esta instalação para de contatar o projectsend.org por novidades.",
"Announcement": "Aviso",
"More": "Mais"
}
+3 -1
View File
@@ -2015,5 +2015,7 @@
"Make this file public": "Сделать этот файл публичным",
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "На этом сайте ещё не настроена публичная страница, поэтому ничего не будет видно, пока администратор её не настроит.",
"Show ProjectSend news on the dashboard": "Показывать новости ProjectSend на панели",
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Раз в день загружает анонсы проекта с projectsend.org для карточки на панели. Если выключить, эта установка вообще перестанет обращаться к projectsend.org за новостями."
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Раз в день загружает анонсы проекта с projectsend.org для карточки на панели. Если выключить, эта установка вообще перестанет обращаться к projectsend.org за новостями.",
"Announcement": "Объявление",
"More": "Ещё"
}
+3 -1
View File
@@ -2015,5 +2015,7 @@
"Make this file public": "Fanya faili hili liwe la umma",
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Tovuti hii bado haina ukurasa wa umma, kwa hivyo hakuna kitakachoonekana hadi msimamizi aweke mmoja.",
"Show ProjectSend news on the dashboard": "Onyesha habari za ProjectSend kwenye dashibodi",
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Huleta matangazo ya mradi kutoka projectsend.org mara moja kwa siku kwa ajili ya kadi ya dashibodi. Ukiizima, usakinishaji huu hautawasiliana kabisa na projectsend.org kwa habari."
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Huleta matangazo ya mradi kutoka projectsend.org mara moja kwa siku kwa ajili ya kadi ya dashibodi. Ukiizima, usakinishaji huu hautawasiliana kabisa na projectsend.org kwa habari.",
"Announcement": "Tangazo",
"More": "Zaidi"
}
+3 -1
View File
@@ -2015,5 +2015,7 @@
"Make this file public": "Bu dosyayı herkese açık yap",
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Bu sitede henüz herkese açık bir sayfa ayarlanmamış, bu yüzden bir yönetici ayarlayana kadar hiçbir şey görünmeyecek.",
"Show ProjectSend news on the dashboard": "ProjectSend haberlerini panelde göster",
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Panel kartı için proje duyurularını günde bir kez projectsend.org adresinden alır. Kapatıldığında bu kurulum haberler için projectsend.org ile hiç bağlantı kurmaz."
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Panel kartı için proje duyurularını günde bir kez projectsend.org adresinden alır. Kapatıldığında bu kurulum haberler için projectsend.org ile hiç bağlantı kurmaz.",
"Announcement": "Duyuru",
"More": "Daha fazla"
}
+3 -1
View File
@@ -2015,5 +2015,7 @@
"Make this file public": "Công khai tệp này",
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Trang web này chưa thiết lập trang công khai, nên sẽ không có gì hiển thị cho đến khi quản trị viên thiết lập.",
"Show ProjectSend news on the dashboard": "Hiển thị tin tức ProjectSend trên bảng điều khiển",
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Tải thông báo của dự án từ projectsend.org mỗi ngày một lần cho thẻ trên bảng điều khiển. Khi tắt, bản cài đặt này sẽ hoàn toàn không liên hệ với projectsend.org để lấy tin tức."
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Tải thông báo của dự án từ projectsend.org mỗi ngày một lần cho thẻ trên bảng điều khiển. Khi tắt, bản cài đặt này sẽ hoàn toàn không liên hệ với projectsend.org để lấy tin tức.",
"Announcement": "Thông báo",
"More": "Thêm"
}
+3 -1
View File
@@ -2015,5 +2015,7 @@
"Make this file public": "将此文件设为公开",
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "本站尚未设置公开页面,因此在管理员设置之前不会显示任何内容。",
"Show ProjectSend news on the dashboard": "在仪表板上显示 ProjectSend 动态",
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "每天一次从 projectsend.org 获取项目公告,用于仪表板卡片。关闭后,本安装将完全不再因动态而连接 projectsend.org。"
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "每天一次从 projectsend.org 获取项目公告,用于仪表板卡片。关闭后,本安装将完全不再因动态而连接 projectsend.org。",
"Announcement": "公告",
"More": "更多"
}
+114
View File
@@ -0,0 +1,114 @@
import { type SharedData } from '@/types';
import { usePage } from '@inertiajs/react';
import { ExternalLink, Megaphone } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { useTranslation } from '@/hooks/use-translation';
export interface Announcement {
title: string;
body: string;
action_label: string | null;
action_url: string | null;
tone: 'info' | 'warning' | string;
}
/**
* One message, shown two ways, from one shared prop — see
* ResolvingAnnouncement. The band is the dashboard; the icon beside the
* notification bell carries the same words to every other page, so
* somebody who never opens the dashboard still meets it once.
*
* Both live in this file deliberately. They have to say the same thing,
* and the reliable way to keep two renderings of one message identical is
* for them to share the component that renders it.
*
* Nothing here knows what it is saying. Title, body and button all arrive
* from whatever listened.
*
* Coloured enough to be read and not enough to alarm: a tinted left edge
* and a matching wash, rather than a saturated block. The first caller
* tells a hosted customer their plan has limits and a bigger one exists,
* which is worth noticing and not worth interrupting for — so it must not
* look like an outage. Both palettes are declared per tone rather than
* derived, so the dark variant is a deliberate colour rather than
* whatever the light one happens to become.
*/
const TONES: Record<string, string> = {
info: 'border-l-sky-500 bg-sky-50 dark:bg-sky-950/40',
warning: 'border-l-amber-500 bg-amber-50 dark:bg-amber-950/40',
};
const DOT_TONES: Record<string, string> = {
info: 'bg-sky-500',
warning: 'bg-amber-500',
};
/** The words, and the button if there is one. Shared by both surfaces. */
function AnnouncementBody({ announcement, compact = false }: { announcement: Announcement; compact?: boolean }) {
return (
<>
<div className="min-w-0">
<p className="text-sm font-semibold">{announcement.title}</p>
<p className="text-muted-foreground mt-1 text-sm">{announcement.body}</p>
</div>
{announcement.action_label && announcement.action_url && (
<Button asChild variant="outline" size="sm" className={compact ? 'w-full bg-transparent' : 'shrink-0 bg-transparent'}>
{/* Always a new tab: the destination is outside this
installation, and taking somebody out of the app
they are working in is not what this should do. */}
<a href={announcement.action_url} target="_blank" rel="noopener noreferrer">
{announcement.action_label}
<ExternalLink className="size-3.5" />
</a>
</Button>
)}
</>
);
}
/** The dashboard band. */
export function AnnouncementBand({ announcement }: { announcement: Announcement }) {
const tone = TONES[announcement.tone] ?? TONES.info;
return (
<div className={`mb-6 flex flex-col gap-3 rounded-lg border border-l-4 p-4 sm:flex-row sm:items-center sm:justify-between ${tone}`}>
<AnnouncementBody announcement={announcement} />
</div>
);
}
/**
* The header icon, beside the notification bell.
*
* Same shape as UpdateAvailableIcon next to it: absent entirely when
* there is nothing to say, rather than a dead control. The dot marks it
* without a count — there is only ever one of these, and "1" on a badge
* would invite somebody to look for the second.
*/
export function AnnouncementIcon() {
const { t } = useTranslation();
const { announcement } = usePage<SharedData>().props;
if (!announcement) {
return null;
}
const dot = DOT_TONES[announcement.tone] ?? DOT_TONES.info;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="relative" aria-label={announcement.title || t('Announcement')}>
<Megaphone className="size-5" />
<span className={`absolute top-0.5 right-0.5 size-2.5 rounded-full ${dot}`} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="flex w-80 flex-col gap-3 p-4">
<AnnouncementBody announcement={announcement} compact />
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -1,3 +1,4 @@
import { AnnouncementIcon } from '@/components/announcement';
import { AppearanceSwitcher } from '@/components/appearance-switcher';
import { Breadcrumbs } from '@/components/breadcrumbs';
import { IconLocaleSwitcher } from '@/components/locale-switcher';
@@ -15,6 +16,7 @@ export function AppSidebarHeader({ breadcrumbs = [] }: { breadcrumbs?: Breadcrum
</div>
<div className="ml-auto flex items-center gap-1">
<UpdateAvailableIcon />
<AnnouncementIcon />
<NotificationBell />
<AppearanceSwitcher />
<IconLocaleSwitcher />
@@ -1,57 +0,0 @@
import { ExternalLink } from 'lucide-react';
import { Button } from '@/components/ui/button';
export interface DashboardCallout {
title: string;
body: string;
action_label: string | null;
action_url: string | null;
tone: 'info' | 'warning' | string;
}
/**
* One band across the top of the dashboard, whose words come from
* whatever asked for it — see ResolvingDashboardCallout. Nothing here
* knows what it is saying, which is the point.
*
* Coloured enough to be read and not enough to alarm: a tinted left edge
* and a matching wash, rather than a saturated block. The first caller
* tells a hosted customer their plan has limits and a bigger one exists,
* which is a thing worth noticing and not a thing worth interrupting for
* — so it must not look like an outage.
*
* Both palettes are declared per tone rather than derived, so the dark
* variant is a deliberate colour rather than whatever the light one
* happens to become.
*/
const TONES: Record<string, string> = {
info: 'border-l-sky-500 bg-sky-50 dark:bg-sky-950/40',
warning: 'border-l-amber-500 bg-amber-50 dark:bg-amber-950/40',
};
export function DashboardCalloutBand({ callout }: { callout: DashboardCallout }) {
const tone = TONES[callout.tone] ?? TONES.info;
return (
<div className={`mb-6 flex flex-col gap-3 rounded-lg border border-l-4 p-4 sm:flex-row sm:items-center sm:justify-between ${tone}`}>
<div className="min-w-0">
<p className="text-sm font-semibold">{callout.title}</p>
<p className="text-muted-foreground mt-1 text-sm">{callout.body}</p>
</div>
{callout.action_label && callout.action_url && (
<Button asChild variant="outline" size="sm" className="shrink-0 bg-transparent">
{/* Always a new tab: the destination is outside this
installation, and taking somebody out of the app
they are working in is not what a dashboard link
should do. */}
<a href={callout.action_url} target="_blank" rel="noopener noreferrer">
{callout.action_label}
<ExternalLink className="size-3.5" />
</a>
</Button>
)}
</div>
);
}
+5 -6
View File
@@ -28,7 +28,7 @@ import { TopClientsWidget, type TopClient } from '@/components/dashboard-widgets
import { TransfersRangeControls, TransfersWidget, type TransferPoint, type TransfersRange } from '@/components/dashboard-widgets/transfers-widget';
import { WidgetBox } from '@/components/dashboard-widgets/widget-box';
import { WidgetsDialog } from '@/components/dashboard-widgets/widgets-dialog';
import { DashboardCalloutBand, type DashboardCallout } from '@/components/dashboard-callout';
import { AnnouncementBand } from '@/components/announcement';
import Heading from '@/components/heading';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@@ -50,7 +50,6 @@ interface DashboardProps {
api: ApiUsageSummary | null;
widget_layout: WidgetLayout;
dashboard_columns: number;
callout: DashboardCallout | null;
}
const COLUMN_CLASSES: Record<number, string> = {
@@ -109,11 +108,10 @@ export default function Dashboard({
expired_files,
api,
widget_layout,
callout,
dashboard_columns,
}: DashboardProps) {
const { t } = useTranslation();
const { update_notice } = usePage<SharedData>().props;
const { announcement, update_notice } = usePage<SharedData>().props;
const [releaseDialogOpen, setReleaseDialogOpen] = useState(false);
const [widgetsDialogOpen, setWidgetsDialogOpen] = useState(false);
const [layout, setLayout] = useState<WidgetLayout>(widget_layout);
@@ -341,8 +339,9 @@ export default function Dashboard({
<div className="space-y-6 px-4 py-6">
{/* Above the heading, not inside the grid: whoever asked
for this wants it read, and the grid is arranged by
each viewer. */}
{callout && <DashboardCalloutBand callout={callout} />}
each viewer. Read from shared props, the same source
the header icon uses, so the two cannot disagree. */}
{announcement && <AnnouncementBand announcement={announcement} />}
<div className="flex flex-wrap items-center justify-between gap-3">
<Heading title={t('Dashboard')} description={t('An overview of this installation')} />
+7
View File
@@ -1,3 +1,4 @@
import { type Announcement } from '@/components/announcement';
import { type InstallKind } from '@/components/update-instructions';
import { LucideIcon } from 'lucide-react';
@@ -111,6 +112,12 @@ export interface SharedData {
capabilities: Capability[];
/** Sidebar entries contributed by packages, already staff-filtered. */
extra_nav_links: ExtraNavLink[];
/**
* One message to put in front of staff, or null. Rendered as a band
* on the dashboard and behind the header icon everywhere else — see
* ResolvingAnnouncement. Shared so both say the same thing.
*/
announcement: Announcement | null;
/** Identity providers that are switched on and fully configured. */
social_login: SocialLoginProvider[];
/** The CAPTCHA in force, or null when this installation has none. */
+44 -12
View File
@@ -3,7 +3,7 @@
declare(strict_types=1);
use App\Models\User;
use App\Modules\Platform\Announcements\Events\ResolvingDashboardCallout;
use App\Modules\Platform\Announcements\Events\ResolvingAnnouncement;
use App\Modules\Platform\Navigation\Events\ResolvingNavigationLinks;
use Illuminate\Support\Facades\Event;
use Inertia\Testing\AssertableInertia;
@@ -24,7 +24,7 @@ beforeEach(function () {
test('with nothing listening the dashboard is exactly what it was', function () {
$this->actingAs($this->admin)->get('/dashboard')->assertInertia(
fn (AssertableInertia $page) => $page->where('callout', null),
fn (AssertableInertia $page) => $page->where('announcement', null),
);
$this->actingAs($this->admin)->get('/dashboard')->assertInertia(
@@ -32,16 +32,16 @@ test('with nothing listening the dashboard is exactly what it was', function ()
);
});
test('a listener can put a band on the dashboard', function () {
Event::listen(ResolvingDashboardCallout::class, function (ResolvingDashboardCallout $event): void {
test('a listener can put a message in front of staff', function () {
Event::listen(ResolvingAnnouncement::class, function (ResolvingAnnouncement $event): void {
$event->show('Heads up', 'Something worth reading.', 'Do the thing', 'https://example.test/', 'warning');
});
$this->actingAs($this->admin)->get('/dashboard')->assertInertia(
fn (AssertableInertia $page) => $page
->where('callout.title', 'Heads up')
->where('callout.action_url', 'https://example.test/')
->where('callout.tone', 'warning'),
->where('announcement.title', 'Heads up')
->where('announcement.action_url', 'https://example.test/')
->where('announcement.tone', 'warning'),
);
});
@@ -75,24 +75,56 @@ test('a client gets no contributed links, even from a listener that adds uncondi
// One band. A dashboard that can accumulate banners accumulates them, and
// the second is what teaches people to skip the first.
test('the first listener to set a callout keeps it', function () {
Event::listen(ResolvingDashboardCallout::class, function (ResolvingDashboardCallout $event): void {
Event::listen(ResolvingAnnouncement::class, function (ResolvingAnnouncement $event): void {
$event->show('First', 'Set first.');
});
Event::listen(ResolvingDashboardCallout::class, function (ResolvingDashboardCallout $event): void {
Event::listen(ResolvingAnnouncement::class, function (ResolvingAnnouncement $event): void {
$event->show('Second', 'Should not win.');
});
$this->actingAs($this->admin)->get('/dashboard')->assertInertia(
fn (AssertableInertia $page) => $page->where('callout.title', 'First'),
fn (AssertableInertia $page) => $page->where('announcement.title', 'First'),
);
});
test('an unknown tone falls back rather than rendering unstyled', function () {
Event::listen(ResolvingDashboardCallout::class, function (ResolvingDashboardCallout $event): void {
Event::listen(ResolvingAnnouncement::class, function (ResolvingAnnouncement $event): void {
$event->show('T', 'B', tone: 'chartreuse');
});
$this->actingAs($this->admin)->get('/dashboard')->assertInertia(
fn (AssertableInertia $page) => $page->where('callout.tone', 'info'),
fn (AssertableInertia $page) => $page->where('announcement.tone', 'info'),
);
});
// The header icon and the dashboard band read one shared prop, so a
// message reaches somebody who never opens the dashboard. Two props would
// have drifted the first time anybody edited one.
test('the same message is available away from the dashboard', function () {
Event::listen(ResolvingAnnouncement::class, function (ResolvingAnnouncement $event): void {
$event->show('Everywhere', 'Not only on the dashboard.');
});
$this->actingAs($this->admin)->get('/system/settings/general')->assertInertia(
fn (AssertableInertia $page) => $page->where('announcement.title', 'Everywhere'),
);
});
// A client's header carries the bell too. Nothing addressed to staff may
// appear there, however careless the listener.
test('a client is never shown one, even from a listener that sets it unconditionally', function () {
Event::listen(ResolvingAnnouncement::class, function (ResolvingAnnouncement $event): void {
if (! $event->isStaff) {
return;
}
$event->show('Staff only', 'Not for clients.');
});
$client = User::factory()->client()->create();
$this->actingAs($client)->get('/my-files')->assertInertia(
fn (AssertableInertia $page) => $page->where('announcement', null),
);
});