mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
fix(agenda): modais usáveis no desktop — criação, sync e feed (C148)
This commit is contained in:
parent
ccc8d6eaf6
commit
087635d08b
12 changed files with 447 additions and 204 deletions
1
docs/changelog/2026-09-12-c148.md
Normal file
1
docs/changelog/2026-09-12-c148.md
Normal file
|
|
@ -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.
|
||||
117
docs/plans/c148-modais-agenda-desktop-impl.md
Normal file
117
docs/plans/c148-modais-agenda-desktop-impl.md
Normal file
|
|
@ -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<br/>remove w-full e max-w-2xl"]
|
||||
A1 --> A2["form min-h-0 flex-1 + corpo min-h-0 flex-1 overflow-y-auto<br/>(morre max-h-[calc(100dvh-14rem)])"]
|
||||
A2 --> A3["Início/Término em sm:grid-cols-2<br/>data+hora inline por campo"]
|
||||
B[Sync Google / Feed ICS desktop] --> B1["DialogContent: flex max-h-[calc(100dvh-2rem)]<br/>flex-col p-0 sm:max-w-xl sm:p-0"]
|
||||
B1 --> B2["header shrink-0 / corpo rolável /<br/>footer shrink-0 com as ações"]
|
||||
B2 --> B3["mobile: composição atual intacta<br/>(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]` + `<div className="overflow-y-auto px-4 pb-2">{content}{actionError}{actionRow}</div>` + `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 `<div data-slot="dialog-scroll-body" className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 py-4"><div className="space-y-4">{content}</div></div>`; rodapé `<div data-slot="dialog-footer" className="shrink-0 border-t px-6 py-4">{actionError}{actionRow}</div>` (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/<data>-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).**
|
||||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -151,7 +151,7 @@ export const ActivityDateTimeField = ({
|
|||
['Minuto', minute, setMinute],
|
||||
] as const
|
||||
).map(([part, value, onChange]) => (
|
||||
<div key={part} className={cn('flex flex-col gap-1', isNarrow ? 'w-16' : 'w-20')}>
|
||||
<div key={part} className="flex w-16 flex-col gap-1">
|
||||
{!isNarrow ? (
|
||||
<span className="text-xs font-medium text-muted-foreground select-none">{part}</span>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -436,11 +436,7 @@ const ActivityOverlayForm = ({
|
|||
const demandsSection = <ActivityDemandFields error={errorFor('demandsJson')} />
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
noValidate
|
||||
className={sheet ? 'flex min-h-0 flex-1 flex-col' : 'flex flex-col'}
|
||||
>
|
||||
<form onSubmit={handleSubmit} noValidate className="flex min-h-0 flex-1 flex-col">
|
||||
{isEdit ? <input type="hidden" name="id" value={editDraft?.id ?? ''} /> : null}
|
||||
<input type="hidden" name="status" value="confirmado" />
|
||||
{allDay ? <input type="hidden" name="allDay" value="on" /> : null}
|
||||
|
|
@ -449,10 +445,11 @@ const ActivityOverlayForm = ({
|
|||
<input type="hidden" name="municipality" value={municipalityValue} />
|
||||
|
||||
<div
|
||||
data-slot="dialog-scroll-body"
|
||||
className={
|
||||
sheet
|
||||
? 'min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 pb-6'
|
||||
: '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'
|
||||
}
|
||||
>
|
||||
{sheet ? (
|
||||
|
|
@ -518,6 +515,7 @@ const ActivityOverlayForm = ({
|
|||
</div>
|
||||
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={
|
||||
sheet
|
||||
? 'flex shrink-0 items-center justify-between gap-3 border-t px-4 py-3'
|
||||
|
|
@ -598,8 +596,8 @@ export const ActivityOverlay = ({
|
|||
</Drawer>
|
||||
) : (
|
||||
<Dialog open onOpenChange={(next) => (next ? undefined : onClose())}>
|
||||
<DialogContent className="flex max-h-[calc(100dvh-2rem)] w-full max-w-2xl flex-col gap-0 overflow-hidden p-0">
|
||||
<DialogHeader className="border-b px-6 py-4">
|
||||
<DialogContent className="flex max-h-[calc(100dvh-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-3xl sm:p-0">
|
||||
<DialogHeader className="shrink-0 border-b px-6 py-4">
|
||||
<DialogTitle>{isEdit ? 'Editar atividade' : 'Nova atividade'}</DialogTitle>
|
||||
{request.kind === 'create' ? (
|
||||
<DialogDescription>{formatBahiaDateTimeLabel(request.startAt)}</DialogDescription>
|
||||
|
|
|
|||
|
|
@ -92,127 +92,125 @@ export const CalendarFeedDialog = ({
|
|||
onOpenChange(false)
|
||||
}
|
||||
|
||||
const body = (
|
||||
const content = createdUrl ? (
|
||||
<>
|
||||
{createdUrl ? (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="feed-url">
|
||||
Link de import
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Input id="feed-url" value={createdUrl} readOnly className="font-mono text-xs" />
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleCopy}
|
||||
aria-label="Copiar link"
|
||||
>
|
||||
<CopyIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{copied && <p className="text-xs text-green-600">Link copiado!</p>}
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-muted p-4 text-sm">
|
||||
<p className="mb-2 font-medium">Como adicionar ao Google Calendar:</p>
|
||||
<ol className="list-inside list-decimal space-y-1 text-muted-foreground">
|
||||
<li>Abra o Google Calendar no computador</li>
|
||||
<li>
|
||||
No menu lateral, clique em {'"'}
|
||||
{'+'}
|
||||
{'"'} ao lado de {'"'}Outras agendas{'"'}
|
||||
</li>
|
||||
<li>
|
||||
Selecione {'"'}Por URL{'"'}
|
||||
</li>
|
||||
<li>
|
||||
Cole o link acima e clique em {'"'}Adicionar agenda{'"'}
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
{!isMobile ? (
|
||||
<Button type="button" variant="outline" onClick={() => handleClose(false)}>
|
||||
Fechar
|
||||
</Button>
|
||||
) : null}
|
||||
<Button asChild>
|
||||
<a
|
||||
href="https://calendar.google.com/calendar/u/0/r/settings/addbyurl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<ExternalLinkIcon className="mr-2 h-4 w-4" />
|
||||
Abrir Google Calendar
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="feed-url">
|
||||
Link de import
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Input id="feed-url" value={createdUrl} readOnly className="font-mono text-xs" />
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleCopy}
|
||||
aria-label="Copiar link"
|
||||
>
|
||||
<CopyIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="feed-label" className="text-sm font-medium">
|
||||
Nome do feed
|
||||
</label>
|
||||
<Input
|
||||
id="feed-label"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder="Ex: Só deputado presente"
|
||||
maxLength={120}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleCreate()}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Um nome para identificar este feed (ex: {'"'}Agenda do candidato{'"'}, {'"'}
|
||||
Reuniões em Salvador{'"'})
|
||||
</p>
|
||||
</div>
|
||||
{copied && <p className="text-xs text-green-600">Link copiado!</p>}
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
<div className="rounded-lg bg-muted p-4 text-sm">
|
||||
<p className="mb-2 font-medium">Como adicionar ao Google Calendar:</p>
|
||||
<ol className="list-inside list-decimal space-y-1 text-muted-foreground">
|
||||
<li>Abra o Google Calendar no computador</li>
|
||||
<li>
|
||||
No menu lateral, clique em {'"'}
|
||||
{'+'}
|
||||
{'"'} ao lado de {'"'}Outras agendas{'"'}
|
||||
</li>
|
||||
<li>
|
||||
Selecione {'"'}Por URL{'"'}
|
||||
</li>
|
||||
<li>
|
||||
Cole o link acima e clique em {'"'}Adicionar agenda{'"'}
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="feed-label" className="text-sm font-medium">
|
||||
Nome do feed
|
||||
</label>
|
||||
<Input
|
||||
id="feed-label"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder="Ex: Só deputado presente"
|
||||
maxLength={120}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleCreate()}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Um nome para identificar este feed (ex: {'"'}Agenda do candidato{'"'}, {'"'}
|
||||
Reuniões em Salvador{'"'})
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{feeds.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">Seus feeds ativos</p>
|
||||
<ul className="space-y-1">
|
||||
{feeds.map((feed) => (
|
||||
<li
|
||||
key={feed.id}
|
||||
className="flex items-center justify-between rounded-lg border px-3 py-2 text-sm"
|
||||
>
|
||||
<span>{feed.label}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => void onRevokeFeed(feed.id)}
|
||||
aria-label={`Revogar feed ${feed.label}`}
|
||||
>
|
||||
<Trash2Icon className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
{!isMobile ? (
|
||||
<Button type="button" variant="outline" onClick={() => handleClose(false)}>
|
||||
Cancelar
|
||||
</Button>
|
||||
) : null}
|
||||
<Button type="button" onClick={handleCreate} disabled={!label.trim() || isCreating}>
|
||||
{isCreating ? 'Gerando...' : 'Gerar link'}
|
||||
</Button>
|
||||
</div>
|
||||
{feeds.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">Seus feeds ativos</p>
|
||||
<ul className="space-y-1">
|
||||
{feeds.map((feed) => (
|
||||
<li
|
||||
key={feed.id}
|
||||
className="flex items-center justify-between rounded-lg border px-3 py-2 text-sm"
|
||||
>
|
||||
<span>{feed.label}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => void onRevokeFeed(feed.id)}
|
||||
aria-label={`Revogar feed ${feed.label}`}
|
||||
>
|
||||
<Trash2Icon className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
const actions = createdUrl ? (
|
||||
<div className="flex justify-end gap-2">
|
||||
{!isMobile ? (
|
||||
<Button type="button" variant="outline" onClick={() => handleClose(false)}>
|
||||
Fechar
|
||||
</Button>
|
||||
) : null}
|
||||
<Button asChild>
|
||||
<a
|
||||
href="https://calendar.google.com/calendar/u/0/r/settings/addbyurl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<ExternalLinkIcon className="mr-2 h-4 w-4" />
|
||||
Abrir Google Calendar
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-end gap-2">
|
||||
{!isMobile ? (
|
||||
<Button type="button" variant="outline" onClick={() => handleClose(false)}>
|
||||
Cancelar
|
||||
</Button>
|
||||
) : null}
|
||||
<Button type="button" onClick={handleCreate} disabled={!label.trim() || isCreating}>
|
||||
{isCreating ? 'Gerando...' : 'Gerar link'}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={handleClose} showSwipeHandle>
|
||||
|
|
@ -224,7 +222,12 @@ export const CalendarFeedDialog = ({
|
|||
Teqo serão refletidas automaticamente.
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<div className="overflow-y-auto px-4 pb-2">{body}</div>
|
||||
<div className="overflow-y-auto px-4 pb-2">
|
||||
<div className="space-y-4">
|
||||
{content}
|
||||
{actions}
|
||||
</div>
|
||||
</div>
|
||||
<DrawerFooter className="border-t">
|
||||
<DrawerCloseButton className="w-full">Fechar</DrawerCloseButton>
|
||||
</DrawerFooter>
|
||||
|
|
@ -235,15 +238,23 @@ export const CalendarFeedDialog = ({
|
|||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<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">
|
||||
<DialogTitle>Sincronizar com Google Calendar</DialogTitle>
|
||||
<DialogDescription>
|
||||
Gere um link de import para sincronizar a agenda com o Google Calendar. Alterações em
|
||||
Teqo serão refletidas automaticamente.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{body}
|
||||
<div
|
||||
data-slot="dialog-scroll-body"
|
||||
className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 py-4"
|
||||
>
|
||||
<div className="space-y-4">{content}</div>
|
||||
</div>
|
||||
<div data-slot="dialog-footer" className="shrink-0 border-t px-6 py-4">
|
||||
{actions}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -183,48 +183,42 @@ export const GoogleCalendarSyncDialog = ({
|
|||
</Button>
|
||||
)
|
||||
|
||||
let body: React.ReactNode
|
||||
let content: React.ReactNode
|
||||
let actions: React.ReactNode = null
|
||||
|
||||
if (state.status === 'not-configured') {
|
||||
body = (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border border-amber-300 bg-amber-50 p-4 text-sm">
|
||||
<p className="mb-2 font-medium text-amber-900">Ainda não configurado</p>
|
||||
<p className="text-amber-800">
|
||||
O Teqo segue 100% funcional — a agenda do Teqo nunca depende do Google. Para ativar o
|
||||
espelho, um administrador precisa concluir a configuração:
|
||||
</p>
|
||||
<ol className="mt-2 list-inside list-decimal space-y-1 text-amber-800">
|
||||
{NOT_CONFIGURED_RUNBOOK.map((step) => (
|
||||
<li key={step}>{step}</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
content = (
|
||||
<div className="rounded-lg border border-amber-300 bg-amber-50 p-4 text-sm">
|
||||
<p className="mb-2 font-medium text-amber-900">Ainda não configurado</p>
|
||||
<p className="text-amber-800">
|
||||
O Teqo segue 100% funcional — a agenda do Teqo nunca depende do Google. Para ativar o
|
||||
espelho, um administrador precisa concluir a configuração:
|
||||
</p>
|
||||
<ol className="mt-2 list-inside list-decimal space-y-1 text-amber-800">
|
||||
{NOT_CONFIGURED_RUNBOOK.map((step) => (
|
||||
<li key={step}>{step}</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
)
|
||||
} else if (state.status === 'disabled') {
|
||||
body = (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg bg-muted p-4 text-sm text-muted-foreground">
|
||||
A sincronização está desativada. A agenda do Teqo continua funcionando normalmente; nada é
|
||||
enviado ao Google enquanto estiver desativada.
|
||||
</div>
|
||||
{actionError && (
|
||||
<p role="alert" className="text-sm text-red-600">
|
||||
{actionError}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex justify-end">
|
||||
<Button type="button" onClick={() => void handleSetDisabled(false)} disabled={isBusy}>
|
||||
Reativar
|
||||
</Button>
|
||||
</div>
|
||||
content = (
|
||||
<div className="rounded-lg bg-muted p-4 text-sm text-muted-foreground">
|
||||
A sincronização está desativada. A agenda do Teqo continua funcionando normalmente; nada é
|
||||
enviado ao Google enquanto estiver desativada.
|
||||
</div>
|
||||
)
|
||||
actions = (
|
||||
<div className="flex justify-end">
|
||||
<Button type="button" onClick={() => void handleSetDisabled(false)} disabled={isBusy}>
|
||||
Reativar
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
const paused = state.status === 'paused'
|
||||
body = (
|
||||
<div className="space-y-4">
|
||||
content = (
|
||||
<>
|
||||
{paused ? (
|
||||
<div className="rounded-lg border border-amber-300 bg-amber-50 p-4 text-sm">
|
||||
<p className="mb-1 font-medium text-amber-900">Pausado — re-tentando</p>
|
||||
|
|
@ -251,40 +245,41 @@ export const GoogleCalendarSyncDialog = ({
|
|||
{linkBlock}
|
||||
{instructions}
|
||||
{reverseEditBlock}
|
||||
|
||||
{actionError && (
|
||||
<p role="alert" className="text-sm text-red-600">
|
||||
{actionError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
{paused ? (
|
||||
<Button asChild variant="outline">
|
||||
<a
|
||||
href="https://calendar.google.com/calendar/u/0/r/settings/addbyurl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<ExternalLinkIcon className="mr-2 h-4 w-4" />
|
||||
Abrir Google Calendar
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
{syncButton}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => void handleSetDisabled(true)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
Desativar
|
||||
</>
|
||||
)
|
||||
actions = (
|
||||
<div className="flex justify-end gap-2">
|
||||
{paused ? (
|
||||
<Button asChild variant="outline">
|
||||
<a
|
||||
href="https://calendar.google.com/calendar/u/0/r/settings/addbyurl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<ExternalLinkIcon className="mr-2 h-4 w-4" />
|
||||
Abrir Google Calendar
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{syncButton}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => void handleSetDisabled(true)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
Desativar
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const errorNotice = actionError ? (
|
||||
<p role="alert" className="text-sm text-red-600">
|
||||
{actionError}
|
||||
</p>
|
||||
) : 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 = ({
|
|||
<DrawerTitle>Agenda da Campanha no Google</DrawerTitle>
|
||||
<DrawerDescription>{description}</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<div className="overflow-y-auto px-4 pb-2">{body}</div>
|
||||
<div className="overflow-y-auto px-4 pb-2">
|
||||
<div className="space-y-4">
|
||||
{content}
|
||||
{errorNotice}
|
||||
{actions}
|
||||
</div>
|
||||
</div>
|
||||
<DrawerFooter className="border-t">
|
||||
<DrawerCloseButton className="w-full">Fechar</DrawerCloseButton>
|
||||
</DrawerFooter>
|
||||
|
|
@ -312,12 +313,23 @@ export const GoogleCalendarSyncDialog = ({
|
|||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<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">
|
||||
<DialogTitle>Agenda da Campanha no Google</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{body}
|
||||
<div
|
||||
data-slot="dialog-scroll-body"
|
||||
className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 py-4"
|
||||
>
|
||||
<div className="space-y-4">{content}</div>
|
||||
</div>
|
||||
{errorNotice || actions ? (
|
||||
<div data-slot="dialog-footer" className="shrink-0 space-y-3 border-t px-6 py-4">
|
||||
{errorNotice}
|
||||
{actions}
|
||||
</div>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)', () => {
|
||||
|
|
|
|||
|
|
@ -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)', () => {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import { TooltipProvider } from '@/components/ui/tooltip'
|
|||
|
||||
type DialogProps = Parameters<typeof CalendarFeedDialog>[0]
|
||||
|
||||
const FEED_URL = 'https://teqo.dev/campanha/agenda/ical/secret'
|
||||
|
||||
const renderDialog = (overrides: Partial<DialogProps> = {}) => {
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<GoogleCalendarSyncDialog
|
||||
|
|
@ -86,23 +99,36 @@ describe('GoogleCalendarSyncDialog — seção "Edições pelo Google" (C115)',
|
|||
})
|
||||
|
||||
it('não mostra a seção no estado não configurado', () => {
|
||||
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<typeof ActivityUpdateFeed>[0]['updates']) =>
|
||||
render(<ActivityUpdateFeed updates={updates} />)
|
||||
|
|
|
|||
Loading…
Reference in a new issue