mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
Merge pull request #489 from fsolla/C95-agenda-seletor-de-modo-de-visualizacao-no-header-do-app
C95 — Agenda: seletor de modo de visualização no header do app
This commit is contained in:
commit
de2e807ee0
11 changed files with 525 additions and 27 deletions
120
docs/plans/c95-agenda-seletor-modo-visualizacao-impl.md
Normal file
120
docs/plans/c95-agenda-seletor-modo-visualizacao-impl.md
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
# Impl: C95 — Agenda: seletor de modo de visualização no header do app
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-08-09
|
||||
Issue: #439
|
||||
Intenção: docs/plans/c95-agenda-seletor-modo-visualizacao.md
|
||||
Appetite restante: ~0,5–1 dia eng (herdado); sem ajuste
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** um seletor único de modo de visualização (Dia/Semana/Mês/Lista) no **header do app** — `CampaignDesktopHeader` no desktop, `CampaignMobileTopBar` no mobile — agenda-contextual (só `/campanha/agenda`), compacto (não cresce `min-h-11`/`min-h-14`), substituindo os 4 botões de modo do FullCalendar (`headerToolbar.end`). A escolha persiste por sessão/reload (estado da tela, junto do filtro — que já vive na URL), respeita o recorte do filtro e **vence** a lógica responsiva de resize (semana↔dia) que hoje existe.
|
||||
- **O que NÃO negociar:** navegação prev/next/"Hoje" permanece no FullCalendar; criação inline (C91) intacta; leader lockdown intacto; demais telas `/campanha` sem o seletor; header sem segunda linha e sem aumento de altura; escolha do usuário não é trocada por resize.
|
||||
- **O que reavaliar (hipóteses da intenção):**
|
||||
- "estado de vista eventualmente junto ao `ActivityAgendaState`/URL" → **confirmado**: `view` entra no contrato URL de `ActivityAgendaState` (mesmo mecanismo dos filtros — canônico, fail-closed, share/reload consistentes).
|
||||
- "conectar o seletor ao `changeView` via `calendarRef`" → **via URL**: o seletor navega (`useCampaignListFilterNavigation`), o `ActivityAgenda` reage ao `state.view` com um effect que chama `changeView` — sem ref compartilhado entre header e página.
|
||||
- A lógica responsiva de resize precisa reconciliar com a escolha do usuário — **gate simples**: com `view` na URL, o auto-switch semana↔dia desliga; sem `view`, comportamento atual permanece.
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph AGENDA_PAGE[AGENDA PAGE · server]
|
||||
S[searchParams] --> R[resolveActivityAgendaUrl + restrict → state com view?]
|
||||
end
|
||||
R -->|state| VC[AgendaViewChrome · client]
|
||||
VC -->|registra| H[SetCampaignHeaderAction id=agenda-view]
|
||||
H -->|lê| DH[CampaignDesktopHeader · cluster direito]
|
||||
H -->|lê| MB[CampaignMobileTopBar · junto ao sino]
|
||||
VC -->|opções navegam| NAV[useCampaignListFilterNavigation → buildActivityAgendaHref]
|
||||
NAV -->|router.replace view=X| R
|
||||
R -->|novo state.view| AG[ActivityAgenda]
|
||||
AG -->|effect state.view → changeView| FC[FullCalendar · headerToolbar sem end]
|
||||
```
|
||||
|
||||
**Opções consideradas (estado da vista):** A) URL param `view` no `ActivityAgendaState` | B) contexto React compartilhado header↔página | C) localStorage/sessionStorage + estado local.
|
||||
**Recomendação:** A — "edit the owner": o filtro da agenda já é estado de tela na URL (`municipality`/`deputyPresent`/`tag`); a vista é a mesma classe de estado. Ganha canonicalização/redirect fail-closed de graça (`resolveActivityAgendaUrl`), persistência reload/share consistente, e o seletor no header não precisa de bridge para o `calendarRef` — o FullCalendar reage a `state.view` como já reage a qualquer filtro.
|
||||
**Rejeitadas:** B (o node de header é renderizado pelo shell, fora da árvore da página — provider por-página não o envolve; exigiria contexto no layout, vazamento de domínio de agenda para o shell); C (perde URL canônica/share; dois sources of truth; contraria "junto do filtro").
|
||||
|
||||
**Opções consideradas (controle do seletor):** A) Popover + botão-ghost compacto com rótulo do modo atual + ▾ | B) `NativeSelect` compacto | C) `ToggleGroup` com 4 ícones.
|
||||
**Recomendação:** A — é o "Semana ▾" do esboço da intenção; adapta por superfície como o sino (`text-primary-foreground` no top bar escuro mobile vs `md:text-foreground` no header claro desktop); um único controle compacto na linha existente (não cresce o header). Opções com check do modo ativo.
|
||||
**Rejeitadas:** B (usa tokens `--field-*` claros — destoa do `bg-primary` do top bar mobile; sem trigger customizável); C (4 segmentos não cabem na linha única — anti-goal explícito da intenção).
|
||||
|
||||
**Opções consideradas (reconciliação com o resize):** A) gate `!state.view` no auto-switch existente (com escolha → nada muda no resize; sem escolha → comportamento atual) | B) remover o auto-switch e deixar só `initialView` por viewport | C) canonicalizar o auto-switch na URL (vira escolha).
|
||||
**Recomendação:** A — mudança mínima, preserva o default responsivo atual e faz a escolha vencer; "só o default muda por viewport" da intenção permanece verdadeiro.
|
||||
**Rejeitadas:** B (muda comportamento de C15 além do pedido — o resize continuaria flipando hoje); C (torna o default "escolha" — depois do primeiro resize o usuário ficaria preso no modo auto-escrito; contradiz "só o default muda por viewport").
|
||||
|
||||
**Opções consideradas (mobile):** A) renderizar o mapa `headerActions` no `CampaignMobileTopBar` (o slot C94/C95 compartilhado passa a renderizar nos dois bars; o botão do feed já se auto-esconde com `hidden md:inline-flex`) | B) registro separado mobile-only.
|
||||
**Recomendação:** A — o slot foi construído como "C94/C95 shared slot"; o feed já controla a própria visibilidade por viewport; o seletor nasce visível nos dois. Uma única fonte de registro.
|
||||
**Rejeitadas:** B (duplica o mecanismo de registro que C94 entregou para exatamente isso).
|
||||
|
||||
**Opções consideradas (valores de URL):** A) códigos curtos `view=week|day|month|list` + mapa puro p/ FC ids | B) FC ids diretos `view=timeGridWeek`.
|
||||
**Recomendação:** A — URL curta e estável (independe de internals do FullCalendar); o mapa é 3 linhas testáveis em `activityUi.ts`.
|
||||
**Rejeitadas:** B (URL verbosa acoplada ao FullCalendar; renomear vista no FC vazaria para a URL).
|
||||
|
||||
**Decisão registrada (label do seletor sem `view`):** o label reflete o modo efetivo — `state.view` se presente, senão o default responsivo do viewport (`window.innerWidth < 640` → "Dia", senão "Semana"), medido com `matchMedia` + settle de hidratação (`useNarrowMeasured` em `hooks/use-mobile.ts`). A janela residual (viewport 640–~680 sem sidebar e 768–896 com sidebar 16rem aberta, onde o card do calendário cruza 640 antes do viewport) é cosmética e só ocorre sem escolha explícita — aceita, sem bridge header↔página para o estado efetivo do FC.
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`src/utilities/activityUi.ts`** (extender): `activityAgendaViews = ['week','day','month','list']`, `ActivityAgendaView` type, `activityAgendaViewLabels` (Semana/Dia/Mês/Lista), `activityAgendaViewFcId` (week→timeGridWeek, day→timeGridDay, month→dayGridMonth, list→listMonth), `isActivityAgendaView` guard. `parseActivityAgendaParams` ganha `view` (fail-closed: valor desconhecido → undefined → redirect canônico); `buildActivityAgendaSearchParams` serializa `view` ao final (ordem canônica `municipality,deputyPresent,tag,view`); `activityAgendaParamNames` += `view`; `restrictActivityAgendaState` repassa `view` (sem restrição de acesso — é estado de UI, não recorte de dados); `parseActivityAgendaReturnHref` preserva `view` no round-trip do create.
|
||||
- **`ActivityAgendaFilters.tsx`** (1 linha): "Limpar" preserva `view` (limpa filtros, não o modo de visualização — `setDraft`/`navigate` mantêm `view` atual).
|
||||
- **`AgendaViewSelector.tsx`** (novo, client, `components/campaign/activity/`): Popover compacto — trigger `Button` ghost `h-9 gap-1 px-2` com label do modo efetivo + `ChevronDown`, classes adaptativas `text-primary-foreground hover:bg-primary-foreground/10 md:text-foreground md:hover:bg-muted` (padrão do sino); `aria-label="Modo de visualização"`, `aria-expanded`/`aria-haspopup`. PopoverContent com 4 opções (`role="menu"`/`menuitemradio` + `aria-checked`, check do ativo); cada opção navega `{...state, view}` via `useCampaignListFilterNavigation` (mesmo hook dos filtros → dim do pending boundary na troca). Label fallback sem `view`: `matchMedia('(max-width: 639px)')` com settle de hidratação.
|
||||
- **`AgendaViewChrome.tsx`** (novo, client, espelho de `AgendaFeedChrome`): recebe `state` da página; renderiza `<SetCampaignHeaderAction id="agenda-view">{selector memoizado}</SetCampaignHeaderAction>`. Montado na página **antes** de `<AgendaFeedChrome>` → ordem de inserção no mapa garante cluster `[Semana ▾][Link de import][Notificações][IA]` (gate da intenção).
|
||||
- **`agenda/page.tsx`**: renderiza `<AgendaViewChrome state={state} />` antes do `<AgendaFeedChrome />`.
|
||||
- **`CampaignMobileTopBar.tsx`** (extender): renderiza `Object.entries(headerActions)` entre título e sino (`<div className="ml-auto flex shrink-0 items-center gap-1">`), espelhando o cluster do desktop; wizard-mode inalterado (early return).
|
||||
- **`ActivityAgenda.tsx`**: `headerToolbar.end` removido (fica `{ start: 'prev,next today', center: 'title' }`); `initialView={state.view ? activityAgendaViewFcId[state.view] : 'timeGridWeek'}`; effect `[state.view]` → `calendarRef.current?.getApi().changeView(...)` (skip se já é a vista atual; `datesSet` dispara reload da janela como hoje); `applyResponsiveView` só flips quando `state.view` ausente (mantém `isNarrow` para o inline create C91).
|
||||
- **Migration:** sem migration (nenhuma mudança de schema).
|
||||
- **Access / Consent:** nenhuma mudança — agenda segue `gate: 'staff'`; sem opt-in novo.
|
||||
- **UI:** Impeccable B — encaixe no header global existente, controle compacto, sem redesign. Shape → craft → critique → polish leve; tokens `data-theme='campaign'`.
|
||||
|
||||
### Dados → forma (se aplicável)
|
||||
|
||||
- Não apresento dados novos (vista = estado de tela/navegação, mesma classe do filtro). Forma do controle: dropdown compacto (decisão A acima), espelhando o padrão visual do cluster direito do header.
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Contrato URL (traço dominante)** — `activityUi.ts` (views/labels/mapa/parse/serialize/restrict/returnHref) + unit tests em `activityUi.unit.spec.ts` (parse fail-closed, serialização canônica com `view`, restrict repassa, returnHref preserva, mapa FC). `ActivityAgendaFilters` "Limpar" preserva view. Verificar: URLs canônicas existentes inalteradas para estados sem `view`.
|
||||
2. **Seletor + wiring do header** — `AgendaViewSelector` (Popover compacto + navegação), `AgendaViewChrome` (registro), página (ordem), `CampaignMobileTopBar` (slot no mobile); `ActivityAgenda` (headerToolbar sem `end`, initialView, effect changeView, gate do responsive).
|
||||
3. **Gates** — e2e da agenda atualizado (os asserts `getByRole('tab', /semana|mês|lista/i)` morrem com a remoção do `end` — reescrever para o seletor do header, desktop e mobile); `pnpm gate:fast` na iteração; `pnpm push` no fechamento.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- Mover prev/next/"Hoje" para o header (fica no FullCalendar — corte da intenção).
|
||||
- Persistência entre dispositivos/contas (só URL/tela).
|
||||
- Bridge de estado efetivo do FC para o header (label fallback usa viewport; janela cosmética aceita).
|
||||
- Extrair `useNarrow` compartilhado calendário/seletor (2 call sites com alvos de medição diferentes; DRY <3).
|
||||
- Redesign do FullCalendar (C15) e do slot de header (C94).
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **E2E:** os asserts de `role="tab"` do FullCalendar quebram ao remover `end` — reescrever para o seletor (label `Modo de visualização` + opções), cobrindo desktop e mobile, e adicionar: troca de vista → URL `view=...` → reload persiste → escolha vence resize.
|
||||
- **`useCampaignListFilterNavigation` no header:** o node renderiza no header, dentro de `CampaignListPendingBoundary` do layout (verificado) — transição compartilhada funciona; `router.replace` com `scroll: false`.
|
||||
- **`SetCampaignHeaderAction` re-registra em re-render:** node memoizado (`useMemo` em `[state]`), padrão C94.
|
||||
- **Eventos ao trocar vista:** `changeView` → `datesSet` → `visibleRange` muda → reload da janela (mesmo caminho dos botões atuais do FC; sem regressão).
|
||||
- **Estado com `view` na action do servidor:** `loadActivityAgendaEvents({...state})` ganha `view` — zod `activityAgendaRequestSchema` (non-strict) descarta chaves desconhecidas; sem mudança de schema (verificado).
|
||||
- **Flake pré-existente (`gridcell.nth(1)`):** os testes de slot do calendário (`cria inline`, `Mais detalhes`) flakavam com "Element is not attached to the DOM" no `scrollIntoViewIfNeeded` — o grid do FullCalendar pinta vazio e **substitui os gridcells quando os events chegam**, então o scroll corria contra o re-render. Estabilizado nesta entrega: esperar `Carregando compromissos…` sumir (`toHaveCount(0)`) antes de interagir nos 2 testes de slot (mesmo padrão de waits do C94, agora antes da ação). Suíte `campaignActivity.e2e.spec.ts` completa verde (6/6) com o rebase em main. O QueryError `endAt cannot be queried` apareceu 1×/9 runs (intermitente, em run sem relação com os testes que falharam; não reproduzido após o rebase).
|
||||
|
||||
## Pós-/simplify (reviewers paralelos)
|
||||
|
||||
- **P1 corrigido (desync URL→calendário):** o effect de `changeView` só sincronizava quando `state.view` estava presente; navegar para `/campanha/agenda` sem query (link da sidebar, back/forward) deixava o FullCalendar numa vista órfã (ex: mês) que o seletor não reivindicava. Agora o effect normaliza também na ausência: volta ao default responsivo (narrow → `timeGridDay`, senão `timeGridWeek`) — mesma regra do label do seletor. E2E cobre o caso (goto sem `view` → "Dia" + grid de dia).
|
||||
- **P1 corrigido (e2e só provava o label):** os asserts do teste C95 agora verificam o calendário de fato — o `aria-label` do botão "Hoje" ("Esta semana"/"Este mês"/"Hoje" — o FullCalendar v7 classic hasheia as classes de view) + presença/ausência de `[role="grid"]` (month tem grid, list não). Coberto após troca, após reload, após resize (escolha vence) e após navegação sem `view`.
|
||||
- **P2 corrigido (teclado do menu):** `nav[role="menu"]` ganhou `onKeyDown` com ArrowUp/ArrowDown/ArrowRight/ArrowLeft/Home/End movendo o foco entre os `menuitemradio` (roving focus mínimo; Escape/foco de volta já vêm do Radix Popover).
|
||||
- **P2 corrigido (3ª cópia do hook de viewport):** `use-mobile.ts` ganhou `useNarrowMeasured(breakpoint)` (mesmo settle de hidratação de `useIsMobileMeasured`); o seletor usa `useNarrowMeasured(640)` em vez de reimplementar matchMedia. `MOBILE_BREAKPOINT` voltou a ser const interna (knip).
|
||||
- **P2 corrigido (labels sem assert unit):** `activityAgendaViewLabels` pinado em unit test (typo "Mêes" falharia); parse de `view=day|list` coberto.
|
||||
- **P3 (banda de divergência):** corrigida a estimativa no doc — com sidebar de 16rem a janela real é 768–896px (não 640–680).
|
||||
- **P3 (Limpar sem cobertura):** e2e do "abre a semana" ganhou assert — após "Limpar", a URL mantém `view=list` (filtros vão, vista fica).
|
||||
|
||||
## Débitos deferidos (gatilho de revisitação)
|
||||
|
||||
| Débito | Gatilho |
|
||||
| ------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
|
||||
| `menuitemradio` sem typeahead; anchor híbrido mantém ctrl/middle-click abrindo href (comportamento de link, não de radio) | Próxima Issue que tocar a11y do menu do header |
|
||||
| Sem caminho de UI para "voltar ao modo automático" (uma vez escolhida, `view` fica na URL; "Limpar" preserva de propósito) | Se produto pedir default responsivo restaurado por um gesto |
|
||||
| `useNarrowViewport` do seletor (viewport 640) vs `applyResponsiveView` do calendário (container 640) — banda residual documentada (768–896 com sidebar) | Se o seletor e o calendário precisarem concordar sempre (mudar o probe do calendário para viewport) |
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto da intenção coberto (seletor único no header desktop+mobile, agenda-contextual, compacto; botões FC removidos; persistência junto do filtro; escolha vence resize; sem regressão de navegação/inline/lockdown)
|
||||
- [ ] Invariantes AGENTS/engineering-standards (URL canônica, staff gate, sem Consent novo, sem migration)
|
||||
- [ ] Testes de domínio: unit do contrato URL (`view` parse/serialize/restrict/returnHref/mapa); e2e da agenda reescrito no seletor + persistência + escolha-vence-resize
|
||||
- [ ] `pnpm gate:fast`, tsc, lint, prettier, knip, check:cycles verdes
|
||||
|
|
@ -10,6 +10,7 @@ import {
|
|||
import { ActivityAgenda } from '@/components/campaign/activity/ActivityAgenda'
|
||||
import { ActivityAgendaFilters } from '@/components/campaign/activity/ActivityAgendaFilters'
|
||||
import { AgendaFeedChrome } from '@/components/campaign/activity/AgendaFeedChrome'
|
||||
import { AgendaViewChrome } from '@/components/campaign/activity/AgendaViewChrome'
|
||||
import {
|
||||
CampaignListPendingBoundary,
|
||||
CampaignListResults,
|
||||
|
|
@ -55,6 +56,9 @@ export default async function AgendaPage({ searchParams }: AgendaPageProps) {
|
|||
|
||||
return (
|
||||
<CampaignPageShell className="gap-6">
|
||||
{/* C95: mounted before AgendaFeedChrome so the header cluster orders
|
||||
[Semana ▾][Link de import][Notificações][IA] (gate da intenção). */}
|
||||
<AgendaViewChrome state={state} />
|
||||
<AgendaFeedChrome
|
||||
feeds={feeds.map((f) => ({
|
||||
id: f.id,
|
||||
|
|
|
|||
|
|
@ -36,7 +36,11 @@ import {
|
|||
activityStatusLabels,
|
||||
type ActivityStatus,
|
||||
} from '@/lib/schemas/activity'
|
||||
import { activitySlotPrefill, type ActivityAgendaState } from '@/utilities/activityUi'
|
||||
import {
|
||||
activityAgendaViewFcId,
|
||||
activitySlotPrefill,
|
||||
type ActivityAgendaState,
|
||||
} from '@/utilities/activityUi'
|
||||
import type { ActivityAgendaEvent } from '@/utilities/activityViewModels'
|
||||
|
||||
import './ActivityAgenda.css'
|
||||
|
|
@ -140,6 +144,9 @@ export const ActivityAgenda = ({
|
|||
narrowRef.current = isNarrow
|
||||
setIsNarrow((current) => (current === isNarrow ? current : isNarrow))
|
||||
if (previous !== null && isNarrow === previous) return
|
||||
// C95 — an explicit view in the URL wins over the responsive week↔day
|
||||
// fallback; without one the narrow default keeps working as before.
|
||||
if (state.view) return
|
||||
const viewType = api.view.type
|
||||
if (isNarrow && viewType === 'timeGridWeek') {
|
||||
api.changeView('timeGridDay')
|
||||
|
|
@ -153,7 +160,24 @@ export const ActivityAgenda = ({
|
|||
const observer = new ResizeObserver(applyResponsiveView)
|
||||
observer.observe(container)
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
}, [state.view])
|
||||
|
||||
useEffect(() => {
|
||||
const api = calendarRef.current?.getApi()
|
||||
if (!api) return
|
||||
if (state.view) {
|
||||
const fcId = activityAgendaViewFcId[state.view]
|
||||
if (api.view.type !== fcId) api.changeView(fcId)
|
||||
return
|
||||
}
|
||||
// The URL lost the explicit view (sidebar link, back/forward): return to
|
||||
// the responsive default so the calendar never keeps an orphan view (e.g.
|
||||
// month) the header selector no longer claims.
|
||||
const isNarrow =
|
||||
(containerRef.current?.getBoundingClientRect().width ?? 0) < MOBILE_BREAKPOINT_PX
|
||||
const fallback = isNarrow ? 'timeGridDay' : 'timeGridWeek'
|
||||
if (api.view.type !== fallback) api.changeView(fallback)
|
||||
}, [state.view])
|
||||
|
||||
useEffect(() => {
|
||||
if (!visibleRange) return
|
||||
|
|
@ -303,11 +327,10 @@ export const ActivityAgenda = ({
|
|||
locale={ptBrLocale}
|
||||
timeZone="America/Bahia"
|
||||
firstDay={1}
|
||||
initialView="timeGridWeek"
|
||||
initialView={state.view ? activityAgendaViewFcId[state.view] : 'timeGridWeek'}
|
||||
headerToolbar={{
|
||||
start: 'prev,next today',
|
||||
center: 'title',
|
||||
end: 'timeGridWeek,timeGridDay,dayGridMonth,listMonth',
|
||||
}}
|
||||
height="auto"
|
||||
slotMinTime="07:00:00"
|
||||
|
|
|
|||
|
|
@ -92,9 +92,12 @@ export const ActivityAgendaFilters = ({
|
|||
|
||||
const runAction = (action: ActivityAgendaOmniboxAction) => {
|
||||
if (action.kind === 'clear') {
|
||||
// C95 — "Limpar" clears the recorte, not the view mode: the view is
|
||||
// screen state alongside the filter, so it survives the clear.
|
||||
const next: ActivityAgendaState = draft.view ? { view: draft.view } : {}
|
||||
setQuery('')
|
||||
setDraft({})
|
||||
navigate({})
|
||||
setDraft(next)
|
||||
navigate(next)
|
||||
return
|
||||
}
|
||||
setDraft(action.state)
|
||||
|
|
|
|||
19
src/components/campaign/activity/AgendaViewChrome.tsx
Normal file
19
src/components/campaign/activity/AgendaViewChrome.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { AgendaViewSelector } from '@/components/campaign/activity/AgendaViewSelector'
|
||||
import { SetCampaignHeaderAction } from '@/components/campaign/shell/CampaignPageChromeContext'
|
||||
import type { ActivityAgendaState } from '@/utilities/activityUi'
|
||||
|
||||
/**
|
||||
* C95 — agenda page → app chrome bridge for the view-mode selector: registers
|
||||
* the compact dropdown into the app header (desktop cluster + mobile top bar)
|
||||
* via the C94/C95 shared header-action slot. Mounted before `AgendaFeedChrome`
|
||||
* so the cluster renders `[Semana ▾][Link de import][Notificações][IA]`.
|
||||
*/
|
||||
export const AgendaViewChrome = ({ state }: { state: ActivityAgendaState }) => {
|
||||
const selector = useMemo(() => <AgendaViewSelector state={state} />, [state])
|
||||
|
||||
return <SetCampaignHeaderAction id="agenda-view">{selector}</SetCampaignHeaderAction>
|
||||
}
|
||||
120
src/components/campaign/activity/AgendaViewSelector.tsx
Normal file
120
src/components/campaign/activity/AgendaViewSelector.tsx
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
'use client'
|
||||
|
||||
import { CheckIcon, ChevronDownIcon } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import { CampaignTransitionAnchor } from '@/components/campaign/shared/CampaignListPending'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
|
||||
import { useNarrowMeasured } from '@/hooks/use-mobile'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
activityAgendaViewLabels,
|
||||
activityAgendaViews,
|
||||
buildActivityAgendaHref,
|
||||
type ActivityAgendaState,
|
||||
type ActivityAgendaView,
|
||||
} from '@/utilities/activityUi'
|
||||
|
||||
/**
|
||||
* Mirrors the calendar's own mobile fallback (`MOBILE_BREAKPOINT_PX` in
|
||||
* `ActivityAgenda`): the selector's fallback label must agree with the view the
|
||||
* calendar will pick on a narrow screen.
|
||||
*/
|
||||
const NARROW_VIEWPORT_PX = 640
|
||||
|
||||
/**
|
||||
* C95 — the agenda-contextual view-mode selector rendered inside the app
|
||||
* header (desktop cluster / mobile top bar). Registered by `AgendaViewChrome`,
|
||||
* so it lives outside the page tree but navigates with the page's own URL
|
||||
* contract (`buildActivityAgendaHref`) and the shared list transition.
|
||||
*/
|
||||
export const AgendaViewSelector = ({ state }: { state: ActivityAgendaState }) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const { isNarrow, measured } = useNarrowMeasured(NARROW_VIEWPORT_PX)
|
||||
const effectiveView: ActivityAgendaView = state.view ?? (measured && isNarrow ? 'day' : 'week')
|
||||
|
||||
const options = useMemo(
|
||||
() =>
|
||||
activityAgendaViews.map((view) => ({
|
||||
view,
|
||||
href: buildActivityAgendaHref({ ...state, view }),
|
||||
})),
|
||||
[state],
|
||||
)
|
||||
|
||||
const moveFocus = (event: React.KeyboardEvent<HTMLElement>, fromIndex: number) => {
|
||||
const direction =
|
||||
event.key === 'ArrowDown' || event.key === 'ArrowRight' ? 1 : event.key === 'Home' ? -2 : 0
|
||||
const items = Array.from(
|
||||
event.currentTarget.querySelectorAll<HTMLElement>('[role="menuitemradio"]'),
|
||||
)
|
||||
if (items.length === 0) return
|
||||
let next: number
|
||||
if (event.key === 'Home') next = 0
|
||||
else if (event.key === 'End') next = items.length - 1
|
||||
else if (direction !== 0) next = (fromIndex + direction + items.length) % items.length
|
||||
else return
|
||||
items[next]?.focus()
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Modo de visualização: ${activityAgendaViewLabels[effectiveView]}`}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="menu"
|
||||
className={cn(
|
||||
'inline-flex h-9 shrink-0 items-center gap-1 rounded-md px-2 text-sm font-medium',
|
||||
'text-primary-foreground hover:bg-primary-foreground/10 hover:text-primary-foreground',
|
||||
'md:text-foreground md:hover:bg-muted md:hover:text-foreground',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
)}
|
||||
>
|
||||
{activityAgendaViewLabels[effectiveView]}
|
||||
<ChevronDownIcon className="size-4" aria-hidden="true" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-44 p-1.5">
|
||||
<nav
|
||||
role="menu"
|
||||
aria-label="Modo de visualização"
|
||||
className="flex flex-col gap-0.5"
|
||||
onKeyDown={(event) => {
|
||||
const current = event.currentTarget.querySelector<HTMLElement>(
|
||||
'[role="menuitemradio"]:focus, [role="menuitemradio"][aria-checked="true"]',
|
||||
)
|
||||
const index = Array.from(
|
||||
event.currentTarget.querySelectorAll('[role="menuitemradio"]'),
|
||||
).indexOf(current as HTMLElement)
|
||||
moveFocus(event, index >= 0 ? index : 0)
|
||||
}}
|
||||
>
|
||||
{options.map(({ view, href }) => {
|
||||
const selected = view === effectiveView
|
||||
return (
|
||||
<CampaignTransitionAnchor
|
||||
key={view}
|
||||
href={href}
|
||||
replace
|
||||
scroll={false}
|
||||
role="menuitemradio"
|
||||
aria-checked={selected}
|
||||
className={cn(
|
||||
'flex min-h-10 items-center gap-2 rounded-md px-2 text-sm hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
selected && 'bg-muted font-medium',
|
||||
)}
|
||||
onNavigate={() => setOpen(false)}
|
||||
>
|
||||
<span className="flex-1">{activityAgendaViewLabels[view]}</span>
|
||||
{selected ? <CheckIcon className="size-4" aria-hidden="true" /> : null}
|
||||
</CampaignTransitionAnchor>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,11 +1,12 @@
|
|||
'use client'
|
||||
|
||||
import { ArrowLeft, X } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { Fragment, type ReactNode } from 'react'
|
||||
|
||||
import { useCampaignListTransition } from '@/components/campaign/shared/CampaignListPending'
|
||||
import { CampaignWizardNavLink } from '@/components/campaign/shared/CampaignWizardNavLink'
|
||||
import { useCampaignHomeSearchChrome } from '@/components/campaign/shell/CampaignHomeSearchChromeContext'
|
||||
import { useCampaignHeaderActions } from '@/components/campaign/shell/CampaignPageChromeContext'
|
||||
import { CampaignPageChromeDisplay } from '@/components/campaign/shell/CampaignPageChromeDisplay'
|
||||
import { useCampaignWizardChrome } from '@/components/campaign/shell/CampaignWizardChromeContext'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
|
@ -31,6 +32,7 @@ const wizardTitleSkeletonClass = 'mx-auto motion-reduce:animate-none bg-primary-
|
|||
export const CampaignMobileTopBar = ({ notificationBell }: { notificationBell?: ReactNode }) => {
|
||||
const chrome = useCampaignWizardChrome()
|
||||
const homeSearchChrome = useCampaignHomeSearchChrome()
|
||||
const headerActions = useCampaignHeaderActions()
|
||||
const { isPending } = useCampaignListTransition()
|
||||
|
||||
if (chrome) {
|
||||
|
|
@ -130,7 +132,12 @@ export const CampaignMobileTopBar = ({ notificationBell }: { notificationBell?:
|
|||
<SidebarTrigger className="text-primary-foreground" />
|
||||
)}
|
||||
<CampaignPageChromeDisplay layout="mobile" />
|
||||
{notificationBell ? <div className="ml-auto shrink-0">{notificationBell}</div> : null}
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||||
{Object.entries(headerActions).map(([id, node]) => (
|
||||
<Fragment key={id}>{node}</Fragment>
|
||||
))}
|
||||
{notificationBell ?? null}
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,24 +2,24 @@ import * as React from 'react'
|
|||
|
||||
const MOBILE_BREAKPOINT = 768
|
||||
|
||||
function useMobileMeasured(): boolean | undefined {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
||||
function useViewportNarrowMeasured(breakpoint: number): boolean | undefined {
|
||||
const [isNarrow, setIsNarrow] = React.useState<boolean | undefined>(undefined)
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
||||
const mql = window.matchMedia(`(max-width: ${breakpoint - 1}px)`)
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
setIsNarrow(window.innerWidth < breakpoint)
|
||||
}
|
||||
mql.addEventListener('change', onChange)
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
setIsNarrow(window.innerWidth < breakpoint)
|
||||
return () => mql.removeEventListener('change', onChange)
|
||||
}, [])
|
||||
}, [breakpoint])
|
||||
|
||||
return isMobile
|
||||
return isNarrow
|
||||
}
|
||||
|
||||
export function useIsMobile() {
|
||||
return !!useMobileMeasured()
|
||||
return !!useViewportNarrowMeasured(MOBILE_BREAKPOINT)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -29,6 +29,16 @@ export function useIsMobile() {
|
|||
* migration logic (B167) — the hydration settle must not be treated as a resize.
|
||||
*/
|
||||
export function useIsMobileMeasured(): { isMobile: boolean; measured: boolean } {
|
||||
const isMobile = useMobileMeasured()
|
||||
const isMobile = useViewportNarrowMeasured(MOBILE_BREAKPOINT)
|
||||
return { isMobile: !!isMobile, measured: isMobile !== undefined }
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic viewport-narrow signal with the same hydration settle as
|
||||
* `useIsMobileMeasured`, at a caller-chosen breakpoint (C95 — the agenda view
|
||||
* selector mirrors the calendar's own narrow threshold).
|
||||
*/
|
||||
export function useNarrowMeasured(breakpoint: number): { isNarrow: boolean; measured: boolean } {
|
||||
const isNarrow = useViewportNarrowMeasured(breakpoint)
|
||||
return { isNarrow: !!isNarrow, measured: isNarrow !== undefined }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,13 +42,39 @@ export type ActivityListState = {
|
|||
|
||||
type RawSearchParams = CampaignListRawSearchParams
|
||||
|
||||
/**
|
||||
* C95 — the agenda view modes behind the header selector. Short URL values map
|
||||
* to FullCalendar view ids via `activityAgendaViewFcId` (deliberately decoupled
|
||||
* from the calendar's internals).
|
||||
*/
|
||||
export const activityAgendaViews = ['week', 'day', 'month', 'list'] as const
|
||||
export type ActivityAgendaView = (typeof activityAgendaViews)[number]
|
||||
|
||||
export const activityAgendaViewLabels: Record<ActivityAgendaView, string> = {
|
||||
week: 'Semana',
|
||||
day: 'Dia',
|
||||
month: 'Mês',
|
||||
list: 'Lista',
|
||||
}
|
||||
|
||||
export const activityAgendaViewFcId: Record<ActivityAgendaView, string> = {
|
||||
week: 'timeGridWeek',
|
||||
day: 'timeGridDay',
|
||||
month: 'dayGridMonth',
|
||||
list: 'listMonth',
|
||||
}
|
||||
|
||||
const isActivityAgendaView = (value: string | undefined): value is ActivityAgendaView =>
|
||||
activityAgendaViews.includes(value as ActivityAgendaView)
|
||||
|
||||
export type ActivityAgendaState = {
|
||||
municipality?: number
|
||||
deputyPresent?: true
|
||||
tag?: string
|
||||
view?: ActivityAgendaView
|
||||
}
|
||||
|
||||
const activityAgendaParamNames = ['municipality', 'deputyPresent', 'tag'] as const
|
||||
const activityAgendaParamNames = ['municipality', 'deputyPresent', 'tag', 'view'] as const
|
||||
const activityAgendaParamNameSet = new Set<string>(activityAgendaParamNames)
|
||||
|
||||
const activityTag = (value: string | undefined): string | undefined => {
|
||||
|
|
@ -60,11 +86,14 @@ export const parseActivityAgendaParams = (params: RawSearchParams): ActivityAgen
|
|||
const municipality = strictDecimalInteger(firstValue(params.municipality))
|
||||
const deputyPresent = firstValue(params.deputyPresent) === '1' ? true : undefined
|
||||
const tag = activityTag(firstValue(params.tag))
|
||||
const rawView = firstValue(params.view)
|
||||
const view = isActivityAgendaView(rawView) ? rawView : undefined
|
||||
|
||||
return {
|
||||
...(municipality ? { municipality } : {}),
|
||||
...(deputyPresent ? { deputyPresent } : {}),
|
||||
...(tag ? { tag } : {}),
|
||||
...(view ? { view } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -73,6 +102,7 @@ export const buildActivityAgendaSearchParams = (state: ActivityAgendaState): URL
|
|||
municipality: state.municipality === undefined ? undefined : String(state.municipality),
|
||||
deputyPresent: state.deputyPresent ? '1' : undefined,
|
||||
tag: state.tag,
|
||||
view: state.view,
|
||||
})
|
||||
const params = new URLSearchParams()
|
||||
|
||||
|
|
@ -81,6 +111,7 @@ export const buildActivityAgendaSearchParams = (state: ActivityAgendaState): URL
|
|||
}
|
||||
if (canonicalState.deputyPresent) params.set('deputyPresent', '1')
|
||||
if (canonicalState.tag) params.set('tag', canonicalState.tag)
|
||||
if (canonicalState.view) params.set('view', canonicalState.view)
|
||||
|
||||
return params
|
||||
}
|
||||
|
|
@ -100,6 +131,7 @@ export const restrictActivityAgendaState = (
|
|||
: {}),
|
||||
...(state.deputyPresent ? { deputyPresent: true } : {}),
|
||||
...(state.tag && accessibleTags.has(state.tag) ? { tag: state.tag } : {}),
|
||||
...(state.view ? { view: state.view } : {}),
|
||||
})
|
||||
|
||||
export const resolveActivityAgendaUrl = (
|
||||
|
|
@ -213,6 +245,7 @@ export const parseActivityAgendaReturnHref = (
|
|||
municipality: returnUrl.searchParams.get('municipality') ?? undefined,
|
||||
deputyPresent: returnUrl.searchParams.get('deputyPresent') ?? undefined,
|
||||
tag: returnUrl.searchParams.get('tag') ?? undefined,
|
||||
view: returnUrl.searchParams.get('view') ?? undefined,
|
||||
}),
|
||||
accessibleMunicipalityIDs,
|
||||
accessibleTags,
|
||||
|
|
|
|||
|
|
@ -92,9 +92,11 @@ test.describe('Agenda — calendário operacional', () => {
|
|||
|
||||
await expect(campaignPageChrome(page, 'Agenda')).toBeVisible()
|
||||
await expect(page.getByText(title, { exact: true })).toBeVisible({ timeout: 15_000 })
|
||||
await expect(page.getByRole('tab', { name: /semana/i })).toBeVisible()
|
||||
await expect(page.getByRole('tab', { name: /mês/i })).toBeVisible()
|
||||
await expect(page.getByRole('tab', { name: /lista/i })).toBeVisible()
|
||||
// C95 — the four view-mode buttons moved out of the FullCalendar toolbar
|
||||
// into the header selector; the calendar toolbar keeps only prev/today/next.
|
||||
await expect(page.getByRole('tab', { name: /semana|mês|lista/i })).toHaveCount(0)
|
||||
const viewSelector = page.getByRole('button', { name: 'Modo de visualização: Semana' })
|
||||
await expect(viewSelector).toBeVisible()
|
||||
|
||||
const eventLink = page.getByRole('link', { name: new RegExp(title) })
|
||||
const eventBox = await eventLink.boundingBox()
|
||||
|
|
@ -143,8 +145,20 @@ test.describe('Agenda — calendário operacional', () => {
|
|||
.filter({ visible: true })
|
||||
.click()
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await page.getByRole('tab', { name: /lista/i }).click()
|
||||
// C95 — the view-mode control lives in the app top bar on mobile too; the
|
||||
// narrow fallback (no `view` param) shows "Dia" after the resize.
|
||||
const mobileViewSelector = page
|
||||
.getByRole('button', { name: /Modo de visualização/ })
|
||||
.filter({ visible: true })
|
||||
await expect(mobileViewSelector).toHaveAttribute('aria-label', 'Modo de visualização: Dia')
|
||||
await mobileViewSelector.click()
|
||||
await page.getByRole('menuitemradio', { name: 'Lista', exact: true }).click()
|
||||
await expect(page).toHaveURL(/view=list/)
|
||||
await expect(page.getByText(title, { exact: true })).toBeVisible()
|
||||
|
||||
// C95 — "Limpar" clears the recorte (filters), never the view mode.
|
||||
await page.getByRole('button', { name: 'Limpar', exact: true }).click()
|
||||
await expect(page).toHaveURL(`${campaign.baseURL}/campanha/agenda?view=list`)
|
||||
expect(
|
||||
await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth),
|
||||
).toBe(true)
|
||||
|
|
@ -167,13 +181,15 @@ test.describe('Agenda — calendário operacional', () => {
|
|||
await page.goto(`${campaign.baseURL}/campanha/agenda?municipality=${municipality.id}`)
|
||||
|
||||
const dayCell = page.getByRole('gridcell').nth(1)
|
||||
await dayCell.scrollIntoViewIfNeeded()
|
||||
// FullCalendar renders the time axis lazily and re-lays out on mount —
|
||||
// wait for both targets to be attached+visible before measuring, or the
|
||||
// bounding boxes race the grid render.
|
||||
// FullCalendar renders the time axis lazily and re-lays out on mount: the
|
||||
// grid first paints empty and re-renders (replacing its gridcells) when the
|
||||
// events land. Wait for the load to finish before interacting, or the
|
||||
// locator's element is replaced mid-action ("Element is not attached").
|
||||
await expect(page.getByText('Carregando compromissos…')).toHaveCount(0, { timeout: 15_000 })
|
||||
const slotLocator = page.locator('[data-time="14:00:00"]:visible').last()
|
||||
await expect(slotLocator).toBeVisible()
|
||||
await expect(dayCell).toBeVisible()
|
||||
await dayCell.scrollIntoViewIfNeeded()
|
||||
const slotBox = await slotLocator.boundingBox()
|
||||
const dayBox = await dayCell.boundingBox()
|
||||
if (!slotBox || !dayBox) throw new Error('A grade semanal não expôs o slot esperado.')
|
||||
|
|
@ -219,10 +235,15 @@ test.describe('Agenda — calendário operacional', () => {
|
|||
await page.goto(`${campaign.baseURL}/campanha/agenda?municipality=${municipality.id}`)
|
||||
|
||||
const dayCell = page.getByRole('gridcell').nth(1)
|
||||
await dayCell.scrollIntoViewIfNeeded()
|
||||
// FullCalendar renders the time axis lazily and re-lays out on mount: the
|
||||
// grid first paints empty and re-renders (replacing its gridcells) when the
|
||||
// events land. Wait for the load to finish before interacting, or the
|
||||
// locator's element is replaced mid-action ("Element is not attached").
|
||||
await expect(page.getByText('Carregando compromissos…')).toHaveCount(0, { timeout: 15_000 })
|
||||
const slotLocator = page.locator('[data-time="14:00:00"]:visible').last()
|
||||
await expect(slotLocator).toBeVisible()
|
||||
await expect(dayCell).toBeVisible()
|
||||
await dayCell.scrollIntoViewIfNeeded()
|
||||
const slotBox = await slotLocator.boundingBox()
|
||||
const dayBox = await dayCell.boundingBox()
|
||||
if (!slotBox || !dayBox) throw new Error('A grade semanal não expôs o slot esperado.')
|
||||
|
|
@ -250,4 +271,71 @@ test.describe('Agenda — calendário operacional', () => {
|
|||
if (!startAt || !endAt) throw new Error('O formulário não recebeu o intervalo do slot.')
|
||||
expect(new Date(endAt).getTime() - new Date(startAt).getTime()).toBe(1_800_000)
|
||||
})
|
||||
|
||||
test('C95 — seletor de vista no header troca o modo, persiste e vence o resize', async ({
|
||||
campaign,
|
||||
page,
|
||||
}) => {
|
||||
const { fixtures } = campaign
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
|
||||
await campaign.login(page, coordinator.email!, coordinator.password)
|
||||
await page.goto(`${campaign.baseURL}/campanha/agenda`)
|
||||
|
||||
const viewSelector = page
|
||||
.getByRole('button', { name: /Modo de visualização/ })
|
||||
.filter({ visible: true })
|
||||
await expect(viewSelector).toHaveAttribute('aria-label', 'Modo de visualização: Semana')
|
||||
|
||||
// Desktop: switch to month; the URL gains `view=month` and the calendar
|
||||
// actually renders the month grid (not just the selector label). The
|
||||
// FullCalendar v7 classic theme hashes its view classes, so the stable
|
||||
// signal is the "Hoje" button's aria-label ("Este mês") plus a grid.
|
||||
// (`.first()`: FC keeps stale view grids in the DOM while transitioning.)
|
||||
await viewSelector.click()
|
||||
await page.getByRole('menuitemradio', { name: 'Mês', exact: true }).click()
|
||||
await expect(page).toHaveURL(`${campaign.baseURL}/campanha/agenda?view=month`)
|
||||
await expect(viewSelector).toHaveAttribute('aria-label', 'Modo de visualização: Mês')
|
||||
await expect(page.getByRole('button', { name: 'Este mês' })).toBeVisible({ timeout: 15_000 })
|
||||
await expect(page.getByRole('grid').first()).toBeVisible()
|
||||
|
||||
// Persists across a reload (screen state lives beside the filter, on the URL).
|
||||
await page.reload()
|
||||
await expect(page).toHaveURL(`${campaign.baseURL}/campanha/agenda?view=month`)
|
||||
await expect(viewSelector).toHaveAttribute('aria-label', 'Modo de visualização: Mês')
|
||||
await expect(page.getByRole('button', { name: 'Este mês' })).toBeVisible({ timeout: 15_000 })
|
||||
|
||||
// The explicit choice wins over the responsive narrow fallback: shrinking
|
||||
// the viewport must NOT push the calendar back to day/week.
|
||||
// B167: a chat that opened by itself on the desktop panel (RRP settle)
|
||||
// migrates to the full-screen mobile drawer and covers the page — close it
|
||||
// first so the top bar selector stays reachable in the a11y tree.
|
||||
await page
|
||||
.getByRole('button', { name: 'Fechar', exact: true })
|
||||
.filter({ visible: true })
|
||||
.click()
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await expect(viewSelector).toHaveAttribute('aria-label', 'Modo de visualização: Mês')
|
||||
await expect(page.getByRole('button', { name: 'Este mês' })).toBeVisible({ timeout: 15_000 })
|
||||
|
||||
// The mobile top bar selector still switches modes. The list view shares
|
||||
// the "Este mês" today label but drops the calendar grid.
|
||||
await viewSelector.click()
|
||||
await page.getByRole('menuitemradio', { name: 'Lista', exact: true }).click()
|
||||
await expect(page).toHaveURL(`${campaign.baseURL}/campanha/agenda?view=list`)
|
||||
await expect(viewSelector).toHaveAttribute('aria-label', 'Modo de visualização: Lista')
|
||||
await expect(page.getByRole('button', { name: 'Este mês' })).toBeVisible({ timeout: 15_000 })
|
||||
await expect(page.getByRole('grid')).toHaveCount(0, { timeout: 15_000 })
|
||||
|
||||
// A navigation that drops the `view` param returns the calendar to the
|
||||
// responsive default instead of keeping an orphan view the selector no
|
||||
// longer claims (sidebar link / back-forward). Still on the 390px
|
||||
// viewport, so the narrow default is day ("Hoje" today label, grid back).
|
||||
await page.goto(`${campaign.baseURL}/campanha/agenda`)
|
||||
await expect(viewSelector).toHaveAttribute('aria-label', 'Modo de visualização: Dia')
|
||||
await expect(page.getByRole('button', { name: 'Hoje', exact: true })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
})
|
||||
await expect(page.getByRole('grid').first()).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
activityAgendaViewFcId,
|
||||
activityAgendaViewLabels,
|
||||
activityAgendaViews,
|
||||
activitySlotPrefill,
|
||||
activityTabs,
|
||||
buildActivityAgendaHref,
|
||||
|
|
@ -205,6 +208,74 @@ describe('activity agenda URL contract', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('activity agenda view modes (C95)', () => {
|
||||
it('parses only the declared view codes fail-closed', () => {
|
||||
expect(parseActivityAgendaParams({ view: 'month' }).view).toBe('month')
|
||||
expect(parseActivityAgendaParams({ view: 'week' }).view).toBe('week')
|
||||
expect(parseActivityAgendaParams({ view: 'day' }).view).toBe('day')
|
||||
expect(parseActivityAgendaParams({ view: 'list' }).view).toBe('list')
|
||||
expect(parseActivityAgendaParams({ view: 'bogus' }).view).toBeUndefined()
|
||||
expect(parseActivityAgendaParams({ view: 'timeGridMonth' }).view).toBeUndefined()
|
||||
})
|
||||
|
||||
it('maps every declared view code to a FullCalendar view id', () => {
|
||||
expect(activityAgendaViews).toEqual(['week', 'day', 'month', 'list'])
|
||||
expect(activityAgendaViewFcId).toEqual({
|
||||
week: 'timeGridWeek',
|
||||
day: 'timeGridDay',
|
||||
month: 'dayGridMonth',
|
||||
list: 'listMonth',
|
||||
})
|
||||
})
|
||||
|
||||
it('labels every declared view mode in pt-BR for the header selector', () => {
|
||||
expect(activityAgendaViewLabels).toEqual({
|
||||
week: 'Semana',
|
||||
day: 'Dia',
|
||||
month: 'Mês',
|
||||
list: 'Lista',
|
||||
})
|
||||
})
|
||||
|
||||
it('serializes the view last in the canonical agenda query', () => {
|
||||
expect(
|
||||
buildActivityAgendaSearchParams({
|
||||
municipality: 12,
|
||||
deputyPresent: true,
|
||||
tag: 'Comício',
|
||||
view: 'month',
|
||||
}).toString(),
|
||||
).toBe('municipality=12&deputyPresent=1&tag=Com%C3%ADcio&view=month')
|
||||
expect(buildActivityAgendaHref({ view: 'day' })).toBe('/campanha/agenda?view=day')
|
||||
})
|
||||
|
||||
it('canonicalizes an unknown view out of the URL', () => {
|
||||
expect(resolveActivityAgendaUrl({ view: 'bogus', municipality: '12' }).redirectHref).toBe(
|
||||
'/campanha/agenda?municipality=12',
|
||||
)
|
||||
expect(resolveActivityAgendaUrl({ view: 'month' }).redirectHref).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps the view when restricting the agenda state', () => {
|
||||
expect(
|
||||
restrictActivityAgendaState({ tag: 'Fora', view: 'list' }, new Set(), new Set()),
|
||||
).toEqual({ view: 'list' })
|
||||
})
|
||||
|
||||
it('round-trips the view through the create return href', () => {
|
||||
expect(
|
||||
parseActivityAgendaReturnHref(
|
||||
'/campanha/agenda?municipality=12&view=month',
|
||||
new Set([12]),
|
||||
new Set(),
|
||||
),
|
||||
).toBe('/campanha/agenda?municipality=12&view=month')
|
||||
expect(parseActivityAgendaReturnHref('/campanha/agenda?view=bogus', new Set(), new Set())).toBe(
|
||||
'/campanha/agenda',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildActivityAgendaWhere', () => {
|
||||
it('combines visible-range overlap with municipality, deputy and tag filters', () => {
|
||||
expect(
|
||||
|
|
|
|||
Loading…
Reference in a new issue