From 95e4692df44454d21509db57055e3a8e6eda5caa Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Sat, 12 Sep 2026 02:07:22 -0300 Subject: [PATCH] =?UTF-8?q?fix(agenda):=20modais=20us=C3=A1veis=20no=20des?= =?UTF-8?q?ktop=20=E2=80=94=20cria=C3=A7=C3=A3o,=20sync=20e=20feed=20(C148?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/changelog/2026-09-12-c148.md | 1 + docs/plans/c148-modais-agenda-desktop-impl.md | 117 +++++++++ scripts/lib/e2e-affected-manifest.mjs | 5 +- .../activity/ActivityDateTimeField.tsx | 2 +- .../campaign/activity/ActivityOverlay.tsx | 14 +- .../campaign/activity/CalendarFeedDialog.tsx | 241 +++++++++--------- .../activity/GoogleCalendarSyncDialog.tsx | 144 ++++++----- tests/e2e/campaignActivity.e2e.spec.ts | 14 + .../e2e/campaignAgendaGoogleSync.e2e.spec.ts | 15 ++ tests/unit/activityOverlay.unit.spec.tsx | 14 + tests/unit/calendarFeedDialog.unit.spec.tsx | 34 ++- .../googleCalendarSyncDialog.unit.spec.tsx | 50 +++- 12 files changed, 447 insertions(+), 204 deletions(-) create mode 100644 docs/changelog/2026-09-12-c148.md create mode 100644 docs/plans/c148-modais-agenda-desktop-impl.md diff --git a/docs/changelog/2026-09-12-c148.md b/docs/changelog/2026-09-12-c148.md new file mode 100644 index 00000000..736ea1ea --- /dev/null +++ b/docs/changelog/2026-09-12-c148.md @@ -0,0 +1 @@ +**C148 — Agenda: modais usáveis no desktop (2026-09-12):** os três modais da agenda (`/campanha/atividades` e `/campanha/agenda`) param de brigar com a janela no desktop. O overlay de criação/edição, o dialog de sync Google e o dialog do feed ICS passam a espelhar as variantes `sm:` da base `ui/dialog.tsx` (`sm:max-w-3xl`/`sm:max-w-xl` + `sm:p-0`) — antes os overrides sem variante perdiam para `sm:max-w-md`/`sm:p-8`, e o modal abria com 28rem de largura e padding dobrado (as "bordas gigantes"). Os três ganham corpo rolável (`min-h-0 flex-1 overflow-y-auto`, `data-slot="dialog-scroll-body"`) com header e rodapé `shrink-0` (`data-slot="dialog-footer"`): o conteúdo nunca corta e as ações ficam sempre visíveis — o sync/feed no desktop ganhou rodapé fixo com as ações (o erro da ação fica junto delas no desktop; no mobile a composição antiga foi preservada). O par Início/Término do overlay deixa de empilhar: data+hora inline na mesma linha e selects de hora/minuto com `w-16` (o grid `sm:grid-cols-2` já existia). `ui/dialog.tsx` e os outros 7 consumidores ficam intocados; o contrato mobile (Drawer/`DrawerFooter`) não muda. Testes: asserções estruturais nos units dos três dialogs (conteúdo no corpo, ações no rodapé) e nos e2e (`campaignActivity` mede largura > 600 e o par na mesma linha; `campaignAgendaGoogleSync` encolhe a janela para 560px e prova scroll interno + rodapé visível). O `E2E_AFFECTED_MANIFEST` passa a acordar `campaignAgendaGoogleSync` e `campaignAgendaFeed` para mudanças em `src/components/campaign/activity`. Sem migration, sem access/Consent. diff --git a/docs/plans/c148-modais-agenda-desktop-impl.md b/docs/plans/c148-modais-agenda-desktop-impl.md new file mode 100644 index 00000000..079c6562 --- /dev/null +++ b/docs/plans/c148-modais-agenda-desktop-impl.md @@ -0,0 +1,117 @@ +# Impl: Agenda: modais usáveis no desktop (criação de atividade, sync e feed) + +Status: rascunho +Atualizado em: 2026-09-12 +Issue: #933 +Intenção: docs/plans/c148-modais-agenda-desktop.md +Appetite restante: herdado — ~0,5–1 dia eng. Correção local de encaixe em 3 componentes + testes; sem migration, sem schema, sem access/Consent. + +## Leitura da intenção + +- **Outcome:** no desktop, os três modais da agenda (criação/edição, sync Google, feed ICS) abrem com largura/padding coerentes, nunca cortam conteúdo (corpo rola internamente com o rodapé de ações sempre visível) e o par Início/Término do modal de criação fica lado a lado com data+hora na mesma linha. Mobile intocado. +- **O que NÃO negociar:** (1) mobile dos três modais não regride — o DOM do Drawer/folha atual (overlay `h-[calc(100dvh-1rem)]` + corpo `min-h-0 flex-1 overflow-y-auto`; sync/feed `max-h-[85vh]` + corpo rolável + `DrawerFooter`) permanece exatamente o de hoje; (2) lógica de validação/salvamento e dados intocados; (3) nenhum outro modal do app muda — `ui/dialog.tsx` e seus 10 consumidores intactos; (4) sem migration/Consent (dado interno de staff, `activity` não tem Consent). +- **O que reavaliar:** a hipótese central da intenção está **confirmada**, com mecânica precisa: `cn` = clsx + tailwind-merge resolve conflito de mesma variante (`grid`→`flex`, `p-6`→`p-0`, `gap-4`→`gap-0`, `max-w-sm`→`max-w-2xl`), mas **não** derruba `sm:max-w-md`/`sm:p-8` da base quando o override não tem `sm:`; no CSS do Tailwind v4 a utility com variante é emitida depois e vence em ≥640px. Correção = espelhar a variante (`sm:max-w-3xl sm:p-0`), deixando o twMerge apagar as da base — sem guerra de ordem. Dois defeitos vizinhos no mesmo caminho: `w-full` no overlay anula o gutter de 2rem da base (`w-[calc(100vw-2rem)]`), e `max-h-[calc(100dvh-14rem)]` é número mágico acoplado às alturas de header/footer. O "empilhamento" do par data/hora é sintoma da largura de 28rem (o grid já é `sm:grid-cols-2`; o componente já renderiza data+hora inline), não falta de estrutura — não há layout novo a inventar. +- **Fora de escopo reafirmado:** a divergência de breakpoint (overlay 640 via `useNarrowMeasured(640)` em `ActivityCreateOverlayHost.tsx:35`/`ActivityEditOverlayHost.tsx:34` vs sync/feed 768 via `useIsMobile()` em `src/hooks/use-mobile.ts:3`) **não** será unificada nesta entrega; as fronteiras atuais são preservadas. + +## Abordagem recomendada + +```mermaid +flowchart LR + A[ActivityOverlay desktop] --> A1["DialogContent: sm:max-w-3xl sm:p-0
remove w-full e max-w-2xl"] + A1 --> A2["form min-h-0 flex-1 + corpo min-h-0 flex-1 overflow-y-auto
(morre max-h-[calc(100dvh-14rem)])"] + A2 --> A3["Início/Término em sm:grid-cols-2
data+hora inline por campo"] + B[Sync Google / Feed ICS desktop] --> B1["DialogContent: flex max-h-[calc(100dvh-2rem)]
flex-col p-0 sm:max-w-xl sm:p-0"] + B1 --> B2["header shrink-0 / corpo rolável /
footer shrink-0 com as ações"] + B2 --> B3["mobile: composição atual intacta
(body com ações + DrawerFooter)"] + C["ui/dialog.tsx"] -. "não tocar" .-> A + C -. "não tocar" .-> B +``` + +**Opções consideradas:** A) override local com variantes `sm:` explícitas nos 3 modais | B) mudar a base `ui/dialog.tsx` (largura/padding/scroll default) | C) extrair um shell compartilhado "modal desktop com corpo rolável" para os 3 call sites. +**Recomendação:** A — cada modal já tem conteúdo, padding e largura próprios; a correção é de classe, e o padrão a replicar já existe no repo (QuickActions: `flex max-h-[min(85dvh,40rem)] … overflow-hidden` + filho `overflow-y-auto`, `CampaignQuickActionsOverlay.tsx:166-186`; Advisor: `max-h-[calc(100dvh-2rem)] overflow-y-auto`, `AdvisorPermissionBadge.tsx:86`). Zero superfície de regressão fora da agenda. +**Rejeitadas:** + +- **B — mexer em `ui/dialog.tsx`:** base de 10 imports (`Command`, `PetitionSuccessDialog`, `CampaignNotificationBell`, `CampaignQuickActionsOverlay`, `MunicipalityV2StatusReasonDialog`, `CampaignUpdatesCreateModal`, `AdvisorPermissionBadge` + os 3 da agenda). Mudar `sm:max-w-md`/`sm:p-8`/scroll default é regressão silenciosa em telas não auditadas; a própria intenção marca como rabbit hole. +- **C — shell compartilhado:** seria um pass-through raso (classes + 2 slots) para 3 modais heterogêneos (overlay `p-0` + cards; sync/feed padding próprio e `max-w-xl`), sem conhecimento encapsulado — depth check "pass-through raso → não criar". O repo já resolve isso por receita inline repetida (QuickActions, Advisor, sheet mobile). **Gatilho de revisitação:** 4º modal da agenda com o mesmo corpo rolável, ou adoção do padrão por outro domínio. + +### Decisões de engenharia + +1. **Onde a rolagem do overlay desktop acontece.** Opções: A) `form` `min-h-0 flex-1` + corpo `min-h-0 flex-1 overflow-y-auto`, herdando o `max-h` do `DialogContent` (padrão do sheet mobile e do QuickActions) | B) manter `max-h-[calc(100dvh-14rem)]` e só corrigir largura/padding. **Recomendação: A** — o magic number é a causa do corte quando header/footer mudam de altura; A torna a geometria derivada (`100dvh - 2rem` no content + header/footer `shrink-0`). **Rejeitada:** B porque preserva o acoplamento que gerou o defeito. +2. **Sync/feed: rodapé fixo vs conteúdo inteiro rolável.** Opções: A) header/body/footer em flex, ações extraídas para footer `shrink-0` (draft cena 2; aceite exige "rodapé de ações permanece visível") | B) `overflow-y-auto` no `DialogContent` inteiro (padrão Advisor). **Recomendação: A** — só A satisfaz o aceite; B rola as ações para fora. **Rejeitada:** B (mais simples, porém falha o aceite explícito). +3. **Disposição Início/Término (questão aberta de produto).** Opções: A) dois campos lado a lado, cada um data+hora inline (draft cena 1) | B) um campo por linha com data+hora inline. **Recomendação: A** — o grid `sm:grid-cols-2` (`ActivityOverlay.tsx:355`) já existe; com a largura recuperada cada coluna ganha ~20rem e o gatilho de data (~10rem) mostra `07/08/2026` sem truncar. **Rejeitada:** B (perde a largura recuperada; vira fallback). **Gatilho de revisitação:** se no gate a faixa 640–767px ficar ilegível, cair para B nessa faixa (não antes). +4. **Larguras.** Overlay: `sm:max-w-3xl` (48rem, cena 1) vs `sm:max-w-2xl` (42rem, hipótese da intenção). Sync/feed: `sm:max-w-xl` (36rem, cena 2) vs manter `sm:max-w-lg` (32rem). **Recomendação: 48rem/36rem** — o rascunho UI é o gate aprovado; "largura generosa" e "ampliar pouco" estão materializados nas cenas. **Rejeitadas:** manter 42rem/32rem — a diferença é uma classe e o alvo está no gate. +5. **Como preservar o mobile exatamente.** Opções: A) dividir o `body` em `content` + `actionRow` e recompor o ramo mobile na mesma ordem de hoje | B) footer desktop com `position: sticky` dentro do corpo atual. **Recomendação: A** — DOM mobile idêntico (os unit mobile e os e2e C103/C94/C114 são a prova), sem sticky frágil com padding negativo. **Rejeitada:** B (quebra com `space-y`/padding do content; menos explícito que o flex). + +### Componentes / mudanças + +- **`ActivityOverlay.tsx`** (só caminho desktop): + - `DialogContent` (`:601`): `flex max-h-[calc(100dvh-2rem)] w-full max-w-2xl flex-col gap-0 overflow-hidden p-0` → `flex max-h-[calc(100dvh-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-3xl sm:p-0` (remove `w-full` para voltar o gutter `w-[calc(100vw-2rem)]` da base; `sm:` espelhado derruba `sm:max-w-md`/`sm:p-8` via twMerge). + - `DialogHeader` desktop (`:602`): + `shrink-0` (mantém `border-b px-6 py-4`). + - `form` (`:442`): ramo desktop `flex flex-col` → `flex min-h-0 flex-1 flex-col` (igual ao sheet). + - corpo (`:451-456`): ramo desktop `max-h-[calc(100dvh-14rem)] flex-1 overflow-y-auto overscroll-contain p-6` → `min-h-0 flex-1 overflow-y-auto overscroll-contain p-6`; + `data-slot="dialog-scroll-body"`. + - rodapé (`:520-525`): classes inalteradas (`shrink-0` já existe); + `data-slot="dialog-footer"`. +- **`GoogleCalendarSyncDialog.tsx`:** + - separar o `body` (`:186-286`) em `content` (sem as ações) + `actions` por estado (`not-configured` → nenhuma; `disabled` → Reativar; `synced`/`paused` → Abrir Google Calendar quando `paused` + Sincronizar agora + Desativar). `actionError` vai para o rodapé desktop (junto da ação que falhou) e **fica na posição atual no mobile** (entre content e actions). + - mobile (`:296-310`): composição idêntica à de hoje — `DrawerContent max-h-[85vh]` + `
{content}{actionError}{actionRow}
` + `DrawerFooter` Fechar. + - desktop (`:313-323`): `DialogContent className="flex max-h-[calc(100dvh-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-xl sm:p-0"`; `DialogHeader className="shrink-0 border-b px-6 py-4 pr-12 text-left"`; corpo `
{content}
`; rodapé `
{actionError}{actionRow}
` (não renderiza quando ambos vazios — `not-configured`). +- **`CalendarFeedDialog.tsx`:** + - mesmo split: `content` + `actions` (criado: Fechar desktop + Abrir Google Calendar; senão: Cancelar desktop + Gerar link); erro do form continua no corpo, junto do input. + - mobile (`:216-233`) idêntico; desktop (`:236-248`) com o mesmo shell do sync e `sm:max-w-xl`. +- **`ActivityDateTimeField.tsx`:** uma linha — wrapper dos selects (`:154`) `isNarrow ? 'w-16' : 'w-20'` → `w-16` (o draft cena 1 usa `w-16`; o mobile já era `w-16`, então só o desktop muda). Mantém o rótulo completo da data mesmo na faixa 640–767px. Nenhuma outra mudança no componente. +- **`src/components/ui/dialog.tsx`:** **sem mudança** — base intacta para os 10 consumidores. +- **Testes:** + - unit — `tests/unit/activityOverlay.unit.spec.tsx`, `googleCalendarSyncDialog.unit.spec.tsx`, `calendarFeedDialog.unit.spec.tsx`: asserções estruturais novas (não de classe): botão de ação dentro de `[data-slot="dialog-footer"]` e **fora** de `[data-slot="dialog-scroll-body"]`; conteúdo no corpo. O caminho desktop é o default dos 3 specs (`matchMedia` mockado `matches:false`; overlay `isNarrow=false`). + - e2e — `campaignActivity.e2e.spec.ts` (jornada desktop `:51`): após abrir o modal, `boundingBox` da largura `> 600` (guarda do "estreito"; hoje 448) e `|Δy| < 4` entre Início e Término — o aceite "não empilha" vira asserção. `campaignAgendaGoogleSync.e2e.spec.ts` (teste synced `:138`): encolher o viewport para 1280×560, manter o diálogo aberto, assertar `Sincronizar agora` visível + `[data-slot="dialog-scroll-body"]` com `scrollHeight > clientHeight` + box do diálogo dentro do viewport. Feed: sem e2e novo (mesmo shell do sync; coberto pelo unit estrutural); os e2e existentes (`campaignAgendaFeed.e2e.spec.ts:18` e `:73`) seguem como regressão. +- **Changelog:** `docs/changelog/-c148.md` — uma entrada curta da entrega (nunca editar o agregado; `pnpm changelog:build` gera). +- **Migration:** sem migration (nenhum schema/field). +- **Access / Consent:** N/A — dado interno de staff; nenhum write path muda. +- **UI:** Impeccable B — shape já fixado no rascunho (`docs/plans/c148-modais-agenda-desktop-ui-draft.html`, cenas 1–3); craft = classes desta entrega; critique = gate visual nas 3 cenas (1280px e 390px); polish só se o gate pedir. Shells reusados: padrão do Drawer mobile (header/body/footer com `min-h-0 flex-1 overflow-y-auto`) e o par `flex max-h … overflow-hidden` + filho rolável do QuickActions. + +### Dados → forma (se aplicável) + +N/A — a entrega não apresenta dado novo (pergunta 3 de data-presentation não se aplica); é encaixe de modal. + +## Fases verificáveis + +1. **Tracer — overlay desktop** (quota ~40%): classes em `ActivityOverlay.tsx` (DialogContent/header/form/corpo) + `data-slot` + a linha do `w-16` em `ActivityDateTimeField.tsx`. Verificação: `pnpm test:unit` do `activityOverlay.unit.spec.tsx` verde + abrir `/campanha/atividades` no browser com edit draft longo (todas as seções) e conferir: largura 48rem, padding contido, corpo rola, rodapé visível, Início/Término na mesma linha. Gate visual da cena 1. +2. **Sync + feed desktop** (quota ~40%): split `content`/`actions` nos dois dialogs + shell header/body/footer + `sm:max-w-xl`; conferir no browser a cena 2 e o mobile inalterado (cena 3). Verificação: units dos dois dialogs verdes, incluindo as asserções estruturais novas. +3. **Testes + gates** (quota ~20%): estender os e2e das duas superfícies (asserções acima); rodar os e2e afetados (`campaignActivity`, `campaignAgendaFeed`, `campaignAgendaGoogleSync` + mobile da agenda); `pnpm gate:fast`; `pnpm push` (gate:ci completo sem e2e — o e2e é passo explícito da skill). + +## Rabbit holes / Não escopo (engenharia) + +- Não tocar `ui/dialog.tsx` nem os outros 7 consumidores; não criar `DialogFooter` na base. +- Não unificar os breakpoints 640 vs 768 (`useNarrowMeasured(640)` vs `useIsMobile()`). +- Não extrair shell/constante compartilhada para os 3 modais (ver Decisão C). +- Não redesenhar o formulário: o draft é ilustrativo (Tipo/Status não existem no form real); só encaixe. +- Não mexer no Popover do calendário (`ActivityDateTimeField.tsx:209`, `max-h-[22rem]`) nem no `[data-slot="popover-content"]` dos e2e. +- Não trocar mais nada em `ActivityDateTimeField` além do `w-16` (sem reestruturar a linha data+hora). +- Não guerrear ordem de CSS na mão (`!important`, reordenar classes): usar `sm:` + twMerge. +- Não tocar no mobile (Drawer/`DrawerFooter`/nested sheet) — só a recomposição que preserve o DOM. + +## Riscos e mitigação + +- **Regressão mobile por refatorar o `body`:** mitigação — compor `content + actionError + actionRow` na mesma ordem de hoje no ramo mobile e manter unit/e2e mobile (C103/C94/C114) verdes; revisão de diff focada no ramo `isMobile`/`isNarrow`. +- **`min-h-0` faltando em algum elo** → corpo não rola e corta: mitigação — cadeia `DialogContent (max-h + flex-col) → form (flex-1 min-h-0) → corpo (flex-1 min-h-0 overflow-y-auto)`; testar com edit draft cheio (tasks + demandas) numa janela baixa (1280×560). +- **Asserção de largura do e2e frágil:** mitigação — threshold `> 600` (não o valor exato) e comentário apontando o aceite; o par usa `|Δy| < 4`. +- **e2e de sync em viewport baixo flaky** (conteúdo pode não transbordar): mitigação — viewport 560px com o estado `synced` completo (link + instruções + edições) garante overflow; assert de `scrollHeight > clientHeight` no corpo marcado por `data-slot`. +- **`sm:p-0` remover padding de que o header dependa:** mitigação — header/body/footer ganham padding próprio (`px-6 py-4`) e o X absoluto (`right-3 top-3`) é conferido no gate. +- **Erro de ação sair da vista no desktop:** mitigação — `actionError` vai para o rodapé, ao lado da ação; no mobile fica onde está. +- **Unit estrutural acoplar a `data-slot`:** mitigação — os atributos seguem o padrão de `dialog-content`/`drawer-content` já usado por e2e; sem asserts de classe. + +## Aceite de engenharia + +- [ ] Aceite de produto da intenção ainda coberto: 3 modais com largura/padding coerentes; corpo rolável + rodapé visível; Início/Término lado a lado; mobile intocado. +- [ ] Invariantes AGENTS/engineering-standards: sem migration, sem access/Consent, `ui/dialog.tsx` intacto, contrato mobile preservado, zero warnings, sem dead code (nenhum helper órfão). +- [ ] Testes de domínio previstos: unit existentes verdes + asserções estruturais novas nos 3 specs; int N/A (nenhuma fronteira Payload/DB muda); e2e afetados rodados antes do push. + +## Self-score (decision-quality) + +| Critério | Nota | Justificativa | +| ------------------------------------- | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| 1. Decisões caras têm rejeitadas? | 5/5 | Onde corrigir (A/B/C), técnica de rolagem, rodapé fixo vs scroll total, larguras e fallback A/B do par — todas com rejeitadas explícitas e gatilho de revisitação. | +| 2. Cabe no appetite? | 5/5 | 3 componentes + testes, sem schema/migration; correção local de classes e composição. | +| 3. Rabbit holes nomeados? | 5/5 | Base global, unificação de breakpoint, shell novo, redesign do form, popover/E2E, sticky. | +| 4. Depth check: reusa shells/helpers? | 5/5 | Reusa o padrão existente (sheet mobile, QuickActions, Advisor); nenhum módulo novo. | +| 5. Intenção permanece satisfeita? | 5/5 | Engenharia não reescreveu o outcome: encaixe, não redesign; mobile preservado por contrato de DOM. | + +**Total: 5/5 (gate ≥4).** diff --git a/scripts/lib/e2e-affected-manifest.mjs b/scripts/lib/e2e-affected-manifest.mjs index edb6ef87..2ee626b1 100644 --- a/scripts/lib/e2e-affected-manifest.mjs +++ b/scripts/lib/e2e-affected-manifest.mjs @@ -99,7 +99,10 @@ export const E2E_AFFECTED_MANIFEST = [ 'src/utilities/activityOmnibox', 'src/lib/activityQuickActions', ], - specs: ['campaignActivity'], + // C148 — the activity components host the agenda's three modals: the + // create/edit overlay (campaignActivity), the Google sync dialog and the + // iCal feed dialog. A change there must wake all three surfaces. + specs: ['campaignActivity', 'campaignAgendaGoogleSync', 'campaignAgendaFeed'], }, { prefixes: [ diff --git a/src/components/campaign/activity/ActivityDateTimeField.tsx b/src/components/campaign/activity/ActivityDateTimeField.tsx index e8dd492d..30f32d27 100644 --- a/src/components/campaign/activity/ActivityDateTimeField.tsx +++ b/src/components/campaign/activity/ActivityDateTimeField.tsx @@ -151,7 +151,7 @@ export const ActivityDateTimeField = ({ ['Minuto', minute, setMinute], ] as const ).map(([part, value, onChange]) => ( -
+
{!isNarrow ? ( {part} ) : null} diff --git a/src/components/campaign/activity/ActivityOverlay.tsx b/src/components/campaign/activity/ActivityOverlay.tsx index 1143bef4..52f94f03 100644 --- a/src/components/campaign/activity/ActivityOverlay.tsx +++ b/src/components/campaign/activity/ActivityOverlay.tsx @@ -436,11 +436,7 @@ const ActivityOverlayForm = ({ const demandsSection = return ( -
+ {isEdit ? : null} {allDay ? : null} @@ -449,10 +445,11 @@ const ActivityOverlayForm = ({
{sheet ? ( @@ -518,6 +515,7 @@ const ActivityOverlayForm = ({
) : ( (next ? undefined : onClose())}> - - + + {isEdit ? 'Editar atividade' : 'Nova atividade'} {request.kind === 'create' ? ( {formatBahiaDateTimeLabel(request.startAt)} diff --git a/src/components/campaign/activity/CalendarFeedDialog.tsx b/src/components/campaign/activity/CalendarFeedDialog.tsx index 7175a6ac..402b13b3 100644 --- a/src/components/campaign/activity/CalendarFeedDialog.tsx +++ b/src/components/campaign/activity/CalendarFeedDialog.tsx @@ -92,127 +92,125 @@ export const CalendarFeedDialog = ({ onOpenChange(false) } - const body = ( + const content = createdUrl ? ( <> - {createdUrl ? ( -
-
- -
- - -
- {copied &&

Link copiado!

} -
- -
-

Como adicionar ao Google Calendar:

-
    -
  1. Abra o Google Calendar no computador
  2. -
  3. - No menu lateral, clique em {'"'} - {'+'} - {'"'} ao lado de {'"'}Outras agendas{'"'} -
  4. -
  5. - Selecione {'"'}Por URL{'"'} -
  6. -
  7. - Cole o link acima e clique em {'"'}Adicionar agenda{'"'} -
  8. -
-
- -
- {!isMobile ? ( - - ) : null} - -
+
+ +
+ +
- ) : ( -
-
- - setLabel(e.target.value)} - placeholder="Ex: Só deputado presente" - maxLength={120} - onKeyDown={(e) => e.key === 'Enter' && handleCreate()} - /> -

- Um nome para identificar este feed (ex: {'"'}Agenda do candidato{'"'}, {'"'} - Reuniões em Salvador{'"'}) -

-
+ {copied &&

Link copiado!

} +
- {error &&

{error}

} +
+

Como adicionar ao Google Calendar:

+
    +
  1. Abra o Google Calendar no computador
  2. +
  3. + No menu lateral, clique em {'"'} + {'+'} + {'"'} ao lado de {'"'}Outras agendas{'"'} +
  4. +
  5. + Selecione {'"'}Por URL{'"'} +
  6. +
  7. + Cole o link acima e clique em {'"'}Adicionar agenda{'"'} +
  8. +
+
+ + ) : ( + <> +
+ + setLabel(e.target.value)} + placeholder="Ex: Só deputado presente" + maxLength={120} + onKeyDown={(e) => e.key === 'Enter' && handleCreate()} + /> +

+ Um nome para identificar este feed (ex: {'"'}Agenda do candidato{'"'}, {'"'} + Reuniões em Salvador{'"'}) +

+
- {feeds.length > 0 && ( -
-

Seus feeds ativos

-
    - {feeds.map((feed) => ( -
  • - {feed.label} - -
  • - ))} -
-
- )} + {error &&

{error}

} -
- {!isMobile ? ( - - ) : null} - -
+ {feeds.length > 0 && ( +
+

Seus feeds ativos

+
    + {feeds.map((feed) => ( +
  • + {feed.label} + +
  • + ))} +
)} ) + const actions = createdUrl ? ( +
+ {!isMobile ? ( + + ) : null} + +
+ ) : ( +
+ {!isMobile ? ( + + ) : null} + +
+ ) + if (isMobile) { return ( @@ -224,7 +222,12 @@ export const CalendarFeedDialog = ({ Teqo serão refletidas automaticamente. -
{body}
+
+
+ {content} + {actions} +
+
Fechar @@ -235,15 +238,23 @@ export const CalendarFeedDialog = ({ return ( - - + + Sincronizar com Google Calendar Gere um link de import para sincronizar a agenda com o Google Calendar. Alterações em Teqo serão refletidas automaticamente. - {body} +
+
{content}
+
+
+ {actions} +
) diff --git a/src/components/campaign/activity/GoogleCalendarSyncDialog.tsx b/src/components/campaign/activity/GoogleCalendarSyncDialog.tsx index 15ef10f0..8f93532e 100644 --- a/src/components/campaign/activity/GoogleCalendarSyncDialog.tsx +++ b/src/components/campaign/activity/GoogleCalendarSyncDialog.tsx @@ -183,48 +183,42 @@ export const GoogleCalendarSyncDialog = ({ ) - let body: React.ReactNode + let content: React.ReactNode + let actions: React.ReactNode = null if (state.status === 'not-configured') { - body = ( -
-
-

Ainda não configurado

-

- O Teqo segue 100% funcional — a agenda do Teqo nunca depende do Google. Para ativar o - espelho, um administrador precisa concluir a configuração: -

-
    - {NOT_CONFIGURED_RUNBOOK.map((step) => ( -
  1. {step}
  2. - ))} -
-
+ content = ( +
+

Ainda não configurado

+

+ O Teqo segue 100% funcional — a agenda do Teqo nunca depende do Google. Para ativar o + espelho, um administrador precisa concluir a configuração: +

+
    + {NOT_CONFIGURED_RUNBOOK.map((step) => ( +
  1. {step}
  2. + ))} +
) } else if (state.status === 'disabled') { - body = ( -
-
- A sincronização está desativada. A agenda do Teqo continua funcionando normalmente; nada é - enviado ao Google enquanto estiver desativada. -
- {actionError && ( -

- {actionError} -

- )} -
- -
+ content = ( +
+ A sincronização está desativada. A agenda do Teqo continua funcionando normalmente; nada é + enviado ao Google enquanto estiver desativada. +
+ ) + actions = ( +
+
) } else { const paused = state.status === 'paused' - body = ( -
+ content = ( + <> {paused ? (

Pausado — re-tentando

@@ -251,40 +245,41 @@ export const GoogleCalendarSyncDialog = ({ {linkBlock} {instructions} {reverseEditBlock} - - {actionError && ( -

- {actionError} -

- )} - -
- {paused ? ( - - ) : null} - {syncButton} - -
+ ) : null} + {syncButton} +
) } + const errorNotice = actionError ? ( +

+ {actionError} +

+ ) : null + const description = 'Compromissos do Teqo refletem no calendário Google compartilhado em minutos, e edições feitas nele voltam para o Teqo. Quem segue recebe aviso conforme as próprias configurações.' @@ -301,7 +296,13 @@ export const GoogleCalendarSyncDialog = ({ Agenda da Campanha no Google {description} -
{body}
+
+
+ {content} + {errorNotice} + {actions} +
+
Fechar @@ -312,12 +313,23 @@ export const GoogleCalendarSyncDialog = ({ return ( - - + + Agenda da Campanha no Google {description} - {body} +
+
{content}
+
+ {errorNotice || actions ? ( +
+ {errorNotice} + {actions} +
+ ) : null}
) diff --git a/tests/e2e/campaignActivity.e2e.spec.ts b/tests/e2e/campaignActivity.e2e.spec.ts index 7cb0c1b2..97580e72 100644 --- a/tests/e2e/campaignActivity.e2e.spec.ts +++ b/tests/e2e/campaignActivity.e2e.spec.ts @@ -69,6 +69,20 @@ test.describe('Atividades — registro-fundação', () => { // The central modal (desktop) hosts every section of the old full form. const modal = page.getByRole('dialog', { name: 'Nova atividade' }) await expect(modal).toBeVisible() + // C148 — encaixe desktop: largura generosa (não os 28rem que a base + // `sm:max-w-md` impunha) e Início/Término lado a lado na mesma linha. + const modalBox = await modal.boundingBox() + expect(modalBox?.width ?? 0).toBeGreaterThan(600) + const pairBoxes = await modal.evaluate((dialog) => { + const start = dialog.querySelector('#overlay-startAt')?.getBoundingClientRect() + const end = dialog.querySelector('#overlay-endAt')?.getBoundingClientRect() + return start && end + ? { startY: start.top, endY: end.top, startX: start.left, endX: end.left } + : null + }) + if (!pairBoxes) throw new Error('overlay start/end boxes unavailable') + expect(Math.abs(pairBoxes.startY - pairBoxes.endY)).toBeLessThan(4) + expect(pairBoxes.endX).toBeGreaterThan(pairBoxes.startX) await page.getByLabel('Título *').fill(activityTitle) // The municipality filter prefills the field; the times come from the slot. await expect(page.getByLabel('Município *')).toHaveValue(municipality.name) diff --git a/tests/e2e/campaignAgendaGoogleSync.e2e.spec.ts b/tests/e2e/campaignAgendaGoogleSync.e2e.spec.ts index faa0aa31..341ed795 100644 --- a/tests/e2e/campaignAgendaGoogleSync.e2e.spec.ts +++ b/tests/e2e/campaignAgendaGoogleSync.e2e.spec.ts @@ -170,6 +170,21 @@ test.describe('Agenda — sincronização Google (C114/C122)', () => { await expect(dialog.getByRole('button', { name: 'Copiar link' })).toBeVisible() await expect(dialog.getByText(/Como adicionar ao Google Calendar:/)).toBeVisible() await expect(dialog.getByRole('button', { name: 'Desativar' })).toBeVisible() + + // C148 — na janela baixa o corpo rola dentro do modal e o rodapé de ações + // permanece visível (antes o conteúdo vazava para fora da tela sem scroll). + await page.setViewportSize({ width: 1280, height: 560 }) + // O `100dvh` reflui no frame seguinte ao resize; poll até o box caber. + await expect + .poll(async () => { + const box = await dialog.boundingBox() + return box ? Math.round(box.y + box.height) : Number.POSITIVE_INFINITY + }) + .toBeLessThanOrEqual(560) + const body = dialog.locator('[data-slot="dialog-scroll-body"]') + await expect(body).toBeVisible() + expect(await body.evaluate((el) => el.scrollHeight - el.clientHeight)).toBeGreaterThan(0) + await expect(dialog.getByRole('button', { name: 'Sincronizar agora' })).toBeVisible() }) test.describe('mobile (FAB)', () => { diff --git a/tests/unit/activityOverlay.unit.spec.tsx b/tests/unit/activityOverlay.unit.spec.tsx index bd50be39..71c99266 100644 --- a/tests/unit/activityOverlay.unit.spec.tsx +++ b/tests/unit/activityOverlay.unit.spec.tsx @@ -317,6 +317,20 @@ describe('ActivityOverlay — criação (modal central)', () => { expect(onClose).toHaveBeenCalledTimes(1) expect(mocks.createOverlay).not.toHaveBeenCalled() }) + + it('no modal desktop, mantém o conteúdo no corpo rolável e as ações no rodapé (C148)', () => { + renderOverlay({ agendaState: { municipality: 12 } }) + + const dialog = screen.getByRole('dialog', { name: 'Nova atividade' }) + const body = dialog.querySelector('[data-slot="dialog-scroll-body"]') + const footer = dialog.querySelector('[data-slot="dialog-footer"]') + expect(body).toBeTruthy() + expect(footer).toBeTruthy() + expect(body!.contains(screen.getByLabelText('Título *'))).toBe(true) + expect(body!.contains(screen.getByRole('button', { name: 'Salvar' }))).toBe(false) + expect(footer!.contains(screen.getByRole('button', { name: 'Salvar' }))).toBe(true) + expect(footer!.contains(screen.getByRole('button', { name: 'Cancelar' }))).toBe(true) + }) }) describe('ActivityOverlay — edição (modal central)', () => { diff --git a/tests/unit/calendarFeedDialog.unit.spec.tsx b/tests/unit/calendarFeedDialog.unit.spec.tsx index 1b69adaf..ce94a6ae 100644 --- a/tests/unit/calendarFeedDialog.unit.spec.tsx +++ b/tests/unit/calendarFeedDialog.unit.spec.tsx @@ -6,6 +6,8 @@ import { TooltipProvider } from '@/components/ui/tooltip' type DialogProps = Parameters[0] +const FEED_URL = 'https://teqo.dev/campanha/agenda/ical/secret' + const renderDialog = (overrides: Partial = {}) => { const onCreateFeed = vi.fn() const onRevokeFeed = vi.fn() @@ -72,7 +74,6 @@ describe('CalendarFeedDialog (C93 — no filters needed)', () => { }) it('creates the filterless feed through onCreateFeed with the chosen label', async () => { - const FEED_URL = 'https://teqo.dev/campanha/agenda/ical/secret' const { onCreateFeed } = renderDialog() onCreateFeed.mockResolvedValue({ ok: true, feedUrl: FEED_URL }) @@ -85,4 +86,35 @@ describe('CalendarFeedDialog (C93 — no filters needed)', () => { await waitFor(() => expect(onCreateFeed).toHaveBeenCalledWith('Agenda completa')) await within(dialog).findByDisplayValue(FEED_URL) }) + + it('mantém o conteúdo no corpo rolável e as ações no rodapé (C148)', () => { + renderDialog() + + const dialog = screen.getByRole('dialog') + const body = dialog.querySelector('[data-slot="dialog-scroll-body"]') + const footer = dialog.querySelector('[data-slot="dialog-footer"]') + expect(body).toBeTruthy() + expect(footer).toBeTruthy() + expect(body!.contains(screen.getByLabelText('Nome do feed'))).toBe(true) + expect(body!.contains(screen.getByRole('button', { name: 'Gerar link' }))).toBe(false) + expect(footer!.contains(screen.getByRole('button', { name: 'Gerar link' }))).toBe(true) + }) + + it('no estado criado, o link fica no corpo e as ações no rodapé (C148)', async () => { + const { onCreateFeed } = renderDialog() + onCreateFeed.mockResolvedValue({ ok: true, feedUrl: FEED_URL }) + + const dialog = screen.getByRole('dialog') + fireEvent.change(within(dialog).getByLabelText('Nome do feed'), { + target: { value: 'Agenda completa' }, + }) + fireEvent.click(within(dialog).getByRole('button', { name: 'Gerar link' })) + + const link = await within(dialog).findByDisplayValue(FEED_URL) + const body = dialog.querySelector('[data-slot="dialog-scroll-body"]') + const footer = dialog.querySelector('[data-slot="dialog-footer"]') + expect(body!.contains(link)).toBe(true) + expect(body!.contains(screen.getByRole('link', { name: /Abrir Google Calendar/ }))).toBe(false) + expect(footer!.contains(screen.getByRole('link', { name: /Abrir Google Calendar/ }))).toBe(true) + }) }) diff --git a/tests/unit/googleCalendarSyncDialog.unit.spec.tsx b/tests/unit/googleCalendarSyncDialog.unit.spec.tsx index 79a7a5c1..d93129de 100644 --- a/tests/unit/googleCalendarSyncDialog.unit.spec.tsx +++ b/tests/unit/googleCalendarSyncDialog.unit.spec.tsx @@ -21,6 +21,19 @@ const syncedState = ( ...overrides, }) +const notConfiguredState = (): GoogleCalendarSyncActionResult => ({ + ok: true, + status: 'not-configured', + calendarId: null, + lastSyncedAt: null, + lastSuccessAt: null, + lastErrorAt: null, + lastError: null, + pushChannelExpiresAt: null, + pushChannelError: null, + addLink: null, +}) + const renderDialog = (state: GoogleCalendarSyncActionResult) => render( { - renderDialog({ - ok: true, - status: 'not-configured', - calendarId: null, - lastSyncedAt: null, - lastSuccessAt: null, - lastErrorAt: null, - lastError: null, - pushChannelExpiresAt: null, - pushChannelError: null, - addLink: null, - }) + renderDialog(notConfiguredState()) const dialog = screen.getByRole('dialog', { name: /Agenda da Campanha no Google/ }) expect(within(dialog).queryByText(/Edições pelo Google/)).toBeNull() }) }) +describe('GoogleCalendarSyncDialog — encaixe desktop (C148)', () => { + it('mantém o conteúdo no corpo rolável e as ações no rodapé', () => { + renderDialog(syncedState()) + + const dialog = screen.getByRole('dialog', { name: /Agenda da Campanha no Google/ }) + const body = dialog.querySelector('[data-slot="dialog-scroll-body"]') + const footer = dialog.querySelector('[data-slot="dialog-footer"]') + expect(body).toBeTruthy() + expect(footer).toBeTruthy() + expect(body!.contains(screen.getByText(/Edições pelo Google/))).toBe(true) + expect(body!.contains(screen.getByRole('button', { name: /Sincronizar agora/ }))).toBe(false) + expect(footer!.contains(screen.getByRole('button', { name: /Sincronizar agora/ }))).toBe(true) + expect(footer!.contains(screen.getByRole('button', { name: 'Desativar' }))).toBe(true) + }) + + it('não renderiza rodapé quando não há ação (não configurado)', () => { + renderDialog(notConfiguredState()) + + const dialog = screen.getByRole('dialog', { name: /Agenda da Campanha no Google/ }) + expect(dialog.querySelector('[data-slot="dialog-footer"]')).toBeNull() + expect(dialog.querySelector('[data-slot="dialog-scroll-body"]')).toBeTruthy() + }) +}) + describe('ActivityUpdateFeed — atribuição do registro reverso (C115)', () => { const renderFeed = (updates: Parameters[0]['updates']) => render()