Group dashboard activities under primary administration and tidy editorial overview
tests / ci (push) Waiting to run

This commit is contained in:
2026-11-14 11:43:56 +02:00
parent ce8b744d1f
commit 73e1d7b964
12 changed files with 314 additions and 102 deletions
@@ -57,6 +57,7 @@ class HandleInertiaRequests extends Middleware
return [ return [
...parent::share($request), ...parent::share($request),
'name' => config('app.name'), 'name' => config('app.name'),
'dashboardNavigation' => app(ContentManagement::class)->dashboardNavigation($request->user(), $contentManagementAccess),
'procurementEnabled' => app(Procurement::class)->available(), 'procurementEnabled' => app(Procurement::class)->available(),
'fundingEnabled' => app(FundingProgrammes::class)->installed(), 'fundingEnabled' => app(FundingProgrammes::class)->installed(),
'homologationReserved' => (bool) config('portal.homologation_only', false), 'homologationReserved' => (bool) config('portal.homologation_only', false),
+37
View File
@@ -51,6 +51,43 @@ final class ContentManagement
return $result; return $result;
} }
/** @param array<array-key,mixed> $access
* @return list<array{label:string,href:string}>
*/
public function dashboardNavigation(?User $actor, array $access): array
{
$workflow = app(PortalEditorialWorkflow::class);
if (! $actor || ! $workflow->available() || ! $workflow->canEnter($actor)) {
return [];
}
$items = [['label' => 'Visão geral', 'href' => '/management/editorial']];
$documents = app(DocumentManagementAccess::class);
if ($documents->full($actor) || $documents->editorial($actor)) {
foreach (['article' => 'Artigos', 'legislation' => 'Legislação', 'document' => 'Documentos'] as $kind => $label) {
$items[] = ['label' => $label, 'href' => '/management/editorial?kind='.$kind];
}
}
foreach (['opportunities' => 'Oportunidades', 'clusters' => 'Competitividade', 'portfolio' => 'Carteira', 'demography' => 'Demografia'] as $key => $label) {
if (isset($access[$key]['index'])) {
$items[] = ['label' => $label, 'href' => $access[$key]['index']];
}
}
if (app(PortalEditorialSubjects::class)->available() && $actor->can('manage-portal')) {
$items[] = ['label' => 'Estruturas e relações', 'href' => '/management/editorial/subjects'];
}
$native = app(PortalEditorialNativeNotices::class);
if ($native->access($actor, 'territorial-editorial-v1')) {
$items[] = ['label' => 'Conteúdos territoriais', 'href' => '/management/territorial/editorial'];
}
if ($native->access($actor, 'investment-zones')) {
$items[] = ['label' => 'Zonas e indicadores', 'href' => '/management/investment/zones'];
}
$items[] = ['label' => 'Notificações', 'href' => '/management/editorial#notificacoes'];
return $items;
}
/** /**
* @return array<array-key, mixed> * @return array<array-key, mixed>
*/ */
@@ -1,3 +1,15 @@
## 14/11/2026 — Administração principal e apresentação agrupada do dashboard em QA
Pedido expresso da Tempo: Administração fica no menu principal e Dashboard reúne as actividades num dropdown interno. Esta alteração posterior substitui, apenas para contas com destinos administrativos autorizados, a anterior opção de manter Administração dentro de Mais. Visitantes e contas sem esses destinos conservam os seis acessos públicos; não se apresenta um menu administrativo vazio.
O catálogo de navegação comum é reutilizado no cabeçalho e na navegação lateral. Administração contém Dashboard → Visão geral, Artigos, Legislação, Documentos, Oportunidades, Competitividade, Carteira, Demografia, Estruturas e relações, Conteúdos territoriais, Zonas e indicadores e Notificações, apenas conforme as autorizações e componentes existentes. O painel operacional anterior também passa para Dashboard quando a conta já tem esse destino autorizado. As restantes operações administrativas mantêm os seus destinos, sem duplicar ligações. O grupo activo acompanha a página actual. Em ecrãs pequenos os subgrupos expandem por toque/teclado, sem exigir abertura por hover.
A página `/management/editorial` passa a apresentar três grupos consistentes: **Conteúdos e oportunidades**, **Investimentos e competitividade**, **Território e dados**. Os acessos, contadores e estados existentes ficam alinhados em cartões, com adaptação de uma a três colunas, fila de conteúdos e notificações identificadas. Retiraram-se as ligações dispersas e o segundo bloco repetido de oportunidades. Guardas, filtros, decisões e histórico não mudam.
Provas em `outputs/dashboard-navigation-20261114`: 26 testes de navegação/apresentação, integração de avisos/acesso 4/32, análise estática, tipos e compilação aprovados. Testam-se ausência de administração para visitantes/contas sem âmbito, preservação de destinos, deduplicação, localização do grupo activo, dropdown desktop, expansão móvel, grupos/counters e omissão de áreas indisponíveis. Sem migrações, dados ou permissões alteradas. A conferência visual permanece pendente pelo bloqueio do conector já documentado; os testes de renderização não são apresentados como conferência visual.
**Estado:** implementado/verificado em QA, ainda sem instalação desta nova apresentação. A homologação mantém `a4541298`. Preparar uma única entrega pelo processo Git existente, com backup/restauro e autorização concreta; não reinstalar o complemento anterior nem republicar conteúdos.
## 14/11/2026 — complemento do dashboard instalado e activo em a4541298 ## 14/11/2026 — complemento do dashboard instalado e activo em a4541298
**Instalação conjunta concluída**, após autorização expressa da Tempo. A revisão `a4541298c0a386ab8bfd8e3e531e12d432ec671b` está confirmada em `https://teste-investidor.tempo.co.mz/build/revision.json`. Utilização: `/management/editorial`, com login/MFA e os âmbitos existentes. Estruturas/relações/cadeias, revisão das zonas e indicadores documentais e notificações nativas estão activos. A designação jurídica nominal recebida foi aplicada à configuração protegida; a conta existe e tem correio verificado. Não se alteraram permissões gerais nem se publicaram conteúdos automaticamente. **Instalação conjunta concluída**, após autorização expressa da Tempo. A revisão `a4541298c0a386ab8bfd8e3e531e12d432ec671b` está confirmada em `https://teste-investidor.tempo.co.mz/build/revision.json`. Utilização: `/management/editorial`, com login/MFA e os âmbitos existentes. Estruturas/relações/cadeias, revisão das zonas e indicadores documentais e notificações nativas estão activos. A designação jurídica nominal recebida foi aplicada à configuração protegida; a conta existe e tem correio verificado. Não se alteraram permissões gerais nem se publicaram conteúdos automaticamente.
File diff suppressed because one or more lines are too long
+3
View File
@@ -32,6 +32,7 @@ export function AppSidebar() {
contentManagementAccess, contentManagementAccess,
profileAccess, profileAccess,
scopedAdministration, scopedAdministration,
dashboardNavigation,
} = usePage<{ } = usePage<{
auth: Auth; auth: Auth;
existingBusinessEnabled?: boolean; existingBusinessEnabled?: boolean;
@@ -41,6 +42,7 @@ export function AppSidebar() {
opportunityManagement?: OpportunityManagementAccess | null; opportunityManagement?: OpportunityManagementAccess | null;
contentManagementHref?: string | null; contentManagementHref?: string | null;
scopedAdministration?: { label: string; href: string }[]; scopedAdministration?: { label: string; href: string }[];
dashboardNavigation?: { label: string; href: string }[];
contentManagementAccess?: { contentManagementAccess?: {
opportunities?: { index: string; create?: string | null }; opportunities?: { index: string; create?: string | null };
}; };
@@ -56,6 +58,7 @@ export function AppSidebar() {
contentManagementHref, contentManagementHref,
contentManagementAccess, contentManagementAccess,
scopedAdministration, scopedAdministration,
dashboardNavigation,
legacyContent: currentPage.props.legacyContentEnabled as legacyContent: currentPage.props.legacyContentEnabled as
| boolean | boolean
| undefined, | undefined,
@@ -53,6 +53,7 @@ export function PortalGroupedMenu() {
profileAccess?: ProfileAccess | null; profileAccess?: ProfileAccess | null;
opportunityManagement?: OpportunityManagementAccess | null; opportunityManagement?: OpportunityManagementAccess | null;
scopedAdministration?: { label: string; href: string }[]; scopedAdministration?: { label: string; href: string }[];
dashboardNavigation?: { label: string; href: string }[];
contentManagementHref?: string | null; contentManagementHref?: string | null;
contentManagementAccess?: { contentManagementAccess?: {
opportunities?: { index: string; create?: string | null }; opportunities?: { index: string; create?: string | null };
@@ -79,6 +80,7 @@ export function PortalGroupedMenu() {
contentManagementHref: props.contentManagementHref, contentManagementHref: props.contentManagementHref,
contentManagementAccess: props.contentManagementAccess, contentManagementAccess: props.contentManagementAccess,
scopedAdministration: props.scopedAdministration, scopedAdministration: props.scopedAdministration,
dashboardNavigation: props.dashboardNavigation,
}); });
const current = currentNavigationGroup(groups, url); const current = currentNavigationGroup(groups, url);
const currentItem = currentNavigationItem(groups, url); const currentItem = currentNavigationItem(groups, url);
@@ -93,12 +95,17 @@ export function PortalGroupedMenu() {
children.map((item) => children.map((item) =>
item.children ? ( item.children ? (
mobile ? ( mobile ? (
<Fragment key={item.id}> <details
<DropdownMenuLabel className="mt-2 border-t px-3 pt-3 text-xs text-slate-500"> key={item.id}
className="rounded-lg border-b border-slate-100"
>
<summary className="cursor-pointer px-3 py-3 text-sm font-semibold text-slate-800">
{t(item.label)} {t(item.label)}
</DropdownMenuLabel> </summary>
{items(item.children)} <div className="border-l border-slate-200 pl-2">
</Fragment> {items(item.children)}
</div>
</details>
) : ( ) : (
<DropdownMenuSub key={item.id}> <DropdownMenuSub key={item.id}>
<DropdownMenuSubTrigger className="tempo-menu-item font-semibold"> <DropdownMenuSubTrigger className="tempo-menu-item font-semibold">
@@ -138,6 +145,7 @@ export function PortalGroupedMenu() {
'realization', 'realization',
'support', 'support',
'work', 'work',
'administration',
].includes(group.id), ].includes(group.id),
); );
const institutionalCurrent = url.split('?')[0] === '/contacto'; const institutionalCurrent = url.split('?')[0] === '/contacto';
@@ -203,6 +211,7 @@ export function PortalGroupedMenu() {
'discovery', 'discovery',
'realization', 'realization',
'support', 'support',
'administration',
].includes(group.id), ].includes(group.id),
) )
.map((group) => ( .map((group) => (
+49 -21
View File
@@ -13,6 +13,7 @@ export type PortalNavAccess = {
opportunities?: { index: string; create?: string | null }; opportunities?: { index: string; create?: string | null };
}; };
scopedAdministration?: { label: string; href: string }[]; scopedAdministration?: { label: string; href: string }[];
dashboardNavigation?: { label: string; href: string }[];
procurement?: boolean; procurement?: boolean;
funding?: boolean; funding?: boolean;
authenticated: boolean; authenticated: boolean;
@@ -477,6 +478,44 @@ export function portalNavigation(access: PortalNavAccess): PortalNavItem[] {
? access.opportunityManagement.create.href ? access.opportunityManagement.create.href
: null) : null)
: null; : null;
const dashboardActivities = access.authenticated
? [
...(access.dashboardNavigation ?? []),
...previous
.filter((group) => group.id === 'account')
.flatMap((group) => group.children ?? [])
.filter((item) => item.href === '/dashboard')
.map((item) => ({
label: 'Painel operacional',
href: item.href!,
})),
]
: [];
const administration = access.authenticated
? [
...(dashboardActivities.length
? [
{
id: 'collaborative-dashboard',
label: 'Dashboard',
children: dashboardActivities.map((item, index) =>
link(
'dashboard-activity-' + index,
item.label,
item.href,
),
),
},
]
: []),
...previous
.filter((group) => group.id === 'management')
.flatMap((group) => group.children ?? []),
...(access.scopedAdministration ?? []).map((item, index) =>
link('scoped-' + index, item.label, item.href),
),
]
: [];
return uniqueNavigationDestinations([ return uniqueNavigationDestinations([
{ {
id: 'discovery', id: 'discovery',
@@ -603,6 +642,15 @@ export function portalNavigation(access: PortalNavAccess): PortalNavItem[] {
], ],
}, },
...(administration.length
? [
{
id: 'administration',
label: 'Administração',
children: administration,
},
]
: []),
{ {
id: 'work', id: 'work',
label: 'Área de trabalho', label: 'Área de trabalho',
@@ -633,28 +681,8 @@ export function portalNavigation(access: PortalNavAccess): PortalNavItem[] {
] ]
: []), : []),
...previous ...previous
.filter((group) => .filter((group) => group.id === 'profile-opportunities')
['management', 'profile-opportunities'].includes(
group.id,
),
)
.flatMap((group) => group.children ?? []), .flatMap((group) => group.children ?? []),
...(access.authenticated && access.scopedAdministration?.length
? [
{
id: 'work-administration',
label: 'Administração',
children: access.scopedAdministration.map(
(item, index) =>
link(
'scoped-' + index,
item.label,
item.href,
),
),
},
]
: []),
], ],
}, },
...previous.filter((group) => group.id === 'account'), ...previous.filter((group) => group.id === 'account'),
@@ -85,81 +85,126 @@ export default function EditorialWorkflow({
}) { }) {
const pageUrl = (page: number) => const pageUrl = (page: number) =>
`/management/editorial?${new URLSearchParams({ ...filters, page: String(page) })}`; `/management/editorial?${new URLSearchParams({ ...filters, page: String(page) })}`;
const areas = [
{
title: 'Conteúdos e oportunidades',
entries: [
{
label: 'Artigos',
href: resourceAccess
? '/management/editorial?kind=article'
: null,
},
{
label: 'Legislação',
href: resourceAccess
? '/management/editorial?kind=legislation'
: null,
},
{
label: 'Documentos',
href: resourceAccess
? '/management/editorial?kind=document'
: null,
},
{
label: 'Oportunidades',
href: opportunitiesUrl,
pending: opportunityPending,
},
{
label: 'Biblioteca e editores',
href: resourceAccess ? '/management/portal-services' : null,
},
],
},
{
title: 'Investimentos e competitividade',
entries: [
{
label: 'Carteira',
href: portfolioUrl,
pending: portfolioPending,
},
{
label: 'Competitividade dos clusters',
href: clustersUrl,
pending: nativePending.clusters,
},
{
label: 'Estruturas e relações',
href: subjectsUrl,
pending: subjectsPending,
},
],
},
{
title: 'Território e dados',
entries: [
{
label: 'Conteúdos territoriais',
href: territorialUrl,
pending: nativePending.territories,
},
{
label: 'Zonas e indicadores',
href: zonesUrl,
pending: nativePending.zones ?? 0,
},
{
label: 'Demografia',
href: demographyUrl,
pending: demographyPending,
},
],
},
].filter((area) => area.entries.some((item) => item.href));
return ( return (
<Management title="Circuito editorial"> <Management title="Dashboard">
<p> <p className="max-w-3xl text-sm text-slate-600">
Artigos, legislação e documentos: edições privadas e versão Acompanhe a preparação, revisão e publicação numa única área de
pública separadas. As oportunidades mantêm o seu circuito trabalho. Seleccione uma actividade para continuar nas tarefas
próprio. da sua equipa.
</p> </p>
<div className="flex flex-wrap gap-4"> <section
{zonesUrl && ( aria-label="Actividades do dashboard"
<Link className="underline" href={zonesUrl}> className="grid gap-4 md:grid-cols-2 xl:grid-cols-3"
Zonas e indicadores: {nativePending.zones ?? 0}{' '} >
pendentes {areas.map((area) => (
</Link> <section
)} key={area.title}
{subjectsUrl && ( className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm"
<Link className="underline" href={subjectsUrl}>
Estruturas e relações: {subjectsPending} pendentes
</Link>
)}
{demographyUrl && (
<Link className="underline" href={demographyUrl}>
Demografia: {demographyPending} lotes pendentes
</Link>
)}
{territorialUrl && (
<Link className="underline" href={territorialUrl}>
Conteúdos territoriais: {nativePending.territories}{' '}
pendentes
</Link>
)}
{portfolioUrl && (
<Link className="underline" href={portfolioUrl}>
Carteira: {portfolioPending} fichas pendentes
</Link>
)}
{clustersUrl && (
<Link className="underline" href={clustersUrl}>
Competitividade dos clusters: {nativePending.clusters}{' '}
pendentes
</Link>
)}
<Link className="underline" href="/dashboard">
Painel geral
</Link>
{resourceAccess && (
<Link
className="underline"
href="/management/portal-services"
> >
Biblioteca e editores <h2 className="border-b border-slate-100 bg-slate-50 px-5 py-4 text-sm font-semibold text-slate-900">
</Link> {area.title}
)} </h2>
{opportunitiesUrl && ( <ul className="divide-y divide-slate-100">
<Link className="underline" href={opportunitiesUrl}> {area.entries.map((item) =>
Oportunidades item.href ? (
</Link> <li key={item.label}>
)} <Link
</div> href={item.href}
{opportunitiesUrl && ( className="flex min-h-14 items-center justify-between gap-3 px-5 py-3 text-sm font-medium text-slate-700 transition hover:bg-slate-50 focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-red-700"
<section >
aria-label="Circuito de oportunidades" <span>{item.label}</span>
className="my-4 rounded border p-4" {'pending' in item && (
> <span className="shrink-0 rounded-full bg-slate-100 px-2.5 py-1 text-xs text-slate-600">
<h2 className="font-semibold">Oportunidades</h2> {item.pending} pendentes
<p> </span>
{opportunityPending} conteúdos pendentes no seu âmbito )}
de acesso. </Link>
</p> </li>
<Link className="underline" href={opportunitiesUrl}> ) : null,
Continuar na fila de oportunidades existente )}
</Link> </ul>
</section> </section>
)} ))}
</section>
{resourceAccess && ( {resourceAccess && (
<> <>
<h2 className="text-lg font-semibold text-slate-900">
Fila de conteúdos
</h2>
<form <form
action="/management/editorial" action="/management/editorial"
className="my-4 flex flex-wrap items-end gap-3" className="my-4 flex flex-wrap items-end gap-3"
@@ -239,10 +284,22 @@ export default function EditorialWorkflow({
</nav> </nav>
</> </>
)} )}
<section aria-label="Notificações editoriais"> <section
<h2 className="font-semibold">Notificações</h2> id="notificacoes"
aria-label="Notificações editoriais"
className="rounded-xl border border-slate-200 bg-white p-5 shadow-sm"
>
<h2 className="mb-3 text-lg font-semibold">Notificações</h2>
{notices.length === 0 && (
<p className="text-sm text-slate-500">
Não há notificações neste âmbito.
</p>
)}
{notices.map((n) => ( {notices.map((n) => (
<p key={n.id}> <p
key={n.id}
className="flex flex-wrap items-center justify-between gap-3 border-t border-slate-100 py-3 text-sm"
>
<Link className="underline" href={n.href}> <Link className="underline" href={n.href}>
{eventTitles[n.action] ?? 'Alteração editorial'} ·{' '} {eventTitles[n.action] ?? 'Alteração editorial'} ·{' '}
{n.reference} {n.reference}
@@ -5,6 +5,7 @@ namespace Tests\Feature;
use App\Models\EconomicCluster; use App\Models\EconomicCluster;
use App\Models\User; use App\Models\User;
use App\Services\ClusterCompetitiveness; use App\Services\ClusterCompetitiveness;
use App\Services\ContentManagement;
use App\Services\PortalEditorialNativeNotices; use App\Services\PortalEditorialNativeNotices;
use App\Services\PortalEditorialWorkflow; use App\Services\PortalEditorialWorkflow;
use App\Services\TerritorialRegistry; use App\Services\TerritorialRegistry;
@@ -34,6 +35,19 @@ final class EditorialNativeNoticeTest extends TestCase
$this->actingAsWithMfa($this->author); $this->actingAsWithMfa($this->author);
} }
public function test_dashboard_navigation_uses_existing_scopes_and_does_not_expose_disabled_or_private_activities(): void
{
$service = app(ContentManagement::class);
$items = $service->dashboardNavigation($this->author, $service->access($this->author));
$this->assertSame(['label' => 'Visão geral', 'href' => '/management/editorial'], $items[0]);
$this->assertContains('/management/clusters', array_column($items, 'href'));
$outsider = User::factory()->create();
$this->assertSame([], $service->dashboardNavigation($outsider, []));
$this->assertSame([], $service->dashboardNavigation(null, []));
config(['editorial_workflow.enabled' => false]);
$this->assertSame([], $service->dashboardNavigation($this->author, $service->access($this->author)));
}
public function test_cluster_events_use_the_common_inbox_and_recheck_current_scope_and_event_integrity(): void public function test_cluster_events_use_the_common_inbox_and_recheck_current_scope_and_event_integrity(): void
{ {
$cluster = EconomicCluster::create(['code' => 'NOTICE-'.Str::random(12), 'name' => 'Synthetic notice cluster', 'sector' => 'Energy', 'status' => 'planned']); $cluster = EconomicCluster::create(['code' => 'NOTICE-'.Str::random(12), 'name' => 'Synthetic notice cluster', 'sector' => 'Energy', 'status' => 'planned']);
@@ -0,0 +1,23 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import {createRequire} from 'node:module';
import React from 'react';
import {renderToStaticMarkup} from 'react-dom/server';
import ts from 'typescript';
const require=createRequire(import.meta.url), module={exports:{}};
const code=ts.transpileModule(fs.readFileSync(new URL('../../resources/js/pages/management/editorial-workflow.tsx',import.meta.url),'utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS,jsx:ts.JsxEmit.ReactJSX}}).outputText;
new Function('require','module','exports',code)(name=>name==='@inertiajs/react'?{Link:({href,children,...props})=>React.createElement('a',{href,...props},children),router:{}}:name==='./shared'?{Management:({title,children})=>React.createElement('main',null,React.createElement('h1',null,title),children),Field:({label,children})=>React.createElement('label',null,label,children),button:'',control:''}:require(name),module,module.exports);
const base={items:{data:[],current_page:1,last_page:1},notices:[],resourceAccess:true,opportunitiesUrl:'/opportunities-qa',opportunityPending:2,clustersUrl:'/clusters-qa',nativePending:{clusters:3,territories:0,zones:0},subjectsUrl:'/subjects-qa',territorialUrl:'/territories-qa',demographyUrl:'/demography-qa'};
test('dashboard groups authorised activities once with contextual counts and an empty notification state',()=>{
const html=renderToStaticMarkup(React.createElement(module.exports.default,base));
for(const title of ['Conteúdos e oportunidades','Investimentos e competitividade','Território e dados'])assert.match(html,new RegExp(title));
assert.equal((html.match(/href="\/opportunities-qa"/g)??[]).length,1);
assert.match(html,/2 pendentes/);assert.match(html,/3 pendentes/);
assert.match(html,/Não há notificações neste âmbito/);
assert.match(html,/md:grid-cols-2 xl:grid-cols-3/);
});
test('restricted dashboard omits unavailable groups, private content controls and counters',()=>{
const html=renderToStaticMarkup(React.createElement(module.exports.default,{items:base.items,notices:[],resourceAccess:false,opportunitiesUrl:null,opportunityPending:0}));
assert.doesNotMatch(html,/Fila de conteúdos|kind=legislation|Biblioteca e editores|Investimentos e competitividade|Território e dados/);
});
+16 -2
View File
@@ -238,7 +238,7 @@ void test('work area exposes existing content capability even when the old dashb
); );
assert.ok(work.children.some((i) => i.href === '/gestao-conteudos')); assert.ok(work.children.some((i) => i.href === '/gestao-conteudos'));
assert.ok( assert.ok(
work.children.some((i) => i.href === '/management/opportunities'), destinations(groups.find(g => g.id === 'administration').children).includes('/management/opportunities'),
); );
}); });
void test('every previously authorised management/profile destination survives the regrouping exactly once', () => { void test('every previously authorised management/profile destination survives the regrouping exactly once', () => {
@@ -276,7 +276,7 @@ void test('every previously authorised management/profile destination survives t
assert.equal(after.filter((x) => x === href).length, 1, href); assert.equal(after.filter((x) => x === href).length, 1, href);
assert.equal(after.length, new Set(after).size); assert.equal(after.length, new Set(after).size);
assert.ok( assert.ok(
destinations(groups.find((g) => g.id === 'work').children).includes( destinations(groups.find((g) => g.id === 'administration').children).includes(
'/management/economic-references/lot-01', '/management/economic-references/lot-01',
), ),
); );
@@ -448,3 +448,17 @@ void test('installed concours has its own primary group and preserves public and
); );
} }
}); });
void test('administration owns dashboard activities once and remains hidden without authorised destinations', () => {
const dashboardNavigation = [{label: 'Visão geral', href: '/management/editorial'}, {label: 'Legislação', href: '/management/editorial?kind=legislation'}, {label: 'Competitividade', href: '/management/clusters'}];
const groups = portalNavigation({authenticated: true, manager: true, dashboardNavigation});
const admin = groups.find(g => g.id === 'administration');
const dashboard = admin.children.find(g => g.id === 'collaborative-dashboard');
assert.equal(dashboard.label, 'Dashboard');
assert.deepEqual(destinations(dashboard.children), dashboardNavigation.map(i => i.href));
assert.equal(currentNavigationGroup(groups, '/management/editorial?kind=legislation'), 'administration');
assert.equal(currentNavigationGroup(groups, '/management/clusters/synthetic/edit'), 'administration');
assert.equal(destinations(groups).filter(h => h === '/management/clusters').length, 1);
assert.equal(portalNavigation({authenticated: false, manager: false, dashboardNavigation}).some(g => g.id === 'administration'), false);
assert.equal(portalNavigation({authenticated: true, manager: false}).some(g => g.id === 'administration'), false);
});
@@ -192,3 +192,17 @@ test('actual header has exactly the requested six primary entries on desktop and
), ),
); );
}); });
test('authorised administration is primary and dashboard is a nested expandable group on desktop and mobile', () => {
for (mobile of [false, true]) {
state = {url: '/management/editorial', props: {auth: {user: {name: 'QA'}, canManagePortal: false}, dashboardNavigation: [{label: 'Visão geral', href: '/management/editorial'}, {label: 'Legislação', href: '/management/editorial?kind=legislation'}]}};
const html = renderToStaticMarkup(React.createElement(module.exports.PortalGroupedMenu));
const nav = html.match(/<nav[^>]*aria-label="Áreas do portal"[\s\S]*?<\/nav>/)?.[0];
assert.ok(nav);
const labels = [...nav.matchAll(/<(?:a|button)[^>]*class="tempo-nav-trigger"[^>]*>([\s\S]*?)<\/(?:a|button)>/g)].map(m => m[1].replace(/<[^>]*>/g, '').replace('Grupo actual', '').trim());
assert.deepEqual(labels, ['Início', 'Descoberta', 'Realização', 'Suporte', 'Administração', 'Sobre nós', 'Mais']);
assert.equal((nav.match(/href="\/management\/editorial"/g) ?? []).length, 1);
assert.match(nav, /Dashboard/);
if (mobile) assert.match(nav, /<summary[^>]*>Dashboard<\/summary>/);
}
});