Merge pull request #531 from fsolla/C103-agenda-mobile-formulario-de-criacao-usavel-sheet-do-top
C103 — Agenda mobile: formulário de criação usável (sheet do topo, sem labels, salvar fixo, seletor em bottom sheet)
This commit is contained in:
commit
78680b49ca
9 changed files with 643 additions and 283 deletions
|
|
@ -6,6 +6,10 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
|
|||
|
||||
**Recently resolved (2026-08-10):** **B184** (Issue #514) — no mobile (<768px), o filtro de listas `/campanha` virou o **padrão sem moldura do chassis `CampaignListOmnibox` (B127) — todas as listas**: label oculta (`input` carrega o nome via `aria-label`), campo sem borda/sombra (`md:` restaura o desktop), **form do filtro sticky sob a top bar** com linha separadora edge-to-edge, "Limpar" vira **X circular dentro do input** (aparece com chips OU busca digitada; slot sempre renderizado com `invisible` para a largura não pular; hit target 44px + círculo visual 28px; live region própria "Busca e filtros limpos." — limpar só busca não navega e o pending region ficaria mudo para AT), botão texto "Limpar" desktop-only. **Por que sticky no `<form>`:** sticky não escapa do containing block (o pai) — a coluna do input dentro do wrapper curto do omnibox "grudava" ~12px e rolava junto (medido); o form é o único wrapper filho do `CampaignPageShell` (altura total). **Exceção agenda (C101, merge anterior):** a `/campanha/agenda` já tinha o strip sticky próprio (`activity-agenda-filter-strip`) — mantido; o chassis ainda a cobre no campo/X/Limpar. Em municípios, adicionalmente: **"Salvar filtro" vira icon button no header mobile** (slot C94/C95, `SaveMunicipalityFilterControl presentation='icon'`; popover de nomear intacto; nome de rename é o contrato 2.5.3) e **cards edge-to-edge sem borda** com uma linha separando (`border-b last:border-b-0`, `-mx-4`, gate `md:`). "Nova atualização" (atualizações) — único trailing visível no mobile — foi para o header (mesmo padrão); `campaignMobileHeaderIconClassName` extraída (3º consumidor). E2E novo em `campaignMunicipalities.e2e.spec.ts` (390×844): label oculta, borda 0, sticky, X (limpa chips+busca, foco volta sem reabrir popover), salvar→renomear no header, cards com separador, desktop invertido (1280). Débito registrado no caminho: **D7** (sticky dos headers da tabela desktop morto — wrapper `overflow-x-auto` é o scroll container; medido). Sem migration, access, Consent. Intenção [`municipios-mobile-sem-moldura.md`](plans/municipios-mobile-sem-moldura.md); impl [`municipios-mobile-sem-moldura-impl.md`](plans/municipios-mobile-sem-moldura-impl.md).
|
||||
|
||||
**Recently resolved (2026-08-09):** **C103** (Issue #504) — o formulário de criação rápida da agenda ficou **usável de ponta a ponta no celular**: no narrow (mesmo breakpoint 640px do calendário), o sheet de criação abre **do topo** (`swipeDirection="up"` na primitiva `Drawer` — que já suportava top sheet; zero mudança em `Drawer.tsx`) preenchendo `100dvh-1rem`, o conteúdo **rola** num scroll container e o rodapé ("Mais detalhes" + "Salvar") é **fixo** — verificado em smoke com teclado aberto (viewport 280px: sheet encolhe via `dvh`, conteúdo rola 187/187px e o Salvar permanece visível). O form ganhou `variant: 'popover' | 'sheet'`: no sheet, **labels viram `sr-only`** (a11y e `getByLabelText` intactos), placeholders dão contexto ("Adicionar título _", "Município _", "Local (opcional)", "Adicionar responsáveis"), obrigatórios marcados com asterisco (título/município no placeholder, Início no trigger), campos empilhados **sem gaps com `divide-y`** e inputs/triggers sem borda (lista iOS do canvas). O **seletor de data/hora abre como bottom sheet aninhado** no mobile (`ActivityDateTimeField` ganhou `isNarrow`/`label`/`required`: conteúdo compartilhado Calendar+selects renderizado em `Drawer` com header "Início — <data>" + "Pronto"; desktop continua popover). Props backward-compatible: `placeholder`/`className` no `StrictCombobox`, `labelClassName`/`emptyText`/`triggerClassName` no `ResponsibleMultiSelect`. Decisão do smoke: swipe handle **removido** do top sheet (primitiva o posiciona no rodapé, contradizendo o gesto de swipe-up; continua nos bottom sheets). E2E mobile novo (viewport 390×844): sheet do topo (`y ≤ 1`), Salvar visível sem scroll, picker em bottom sheet com Pronto, evento aparece após salvar; unit narrow cobre placeholders/asterisco/Pronto. Desktop visualmente inalterado (B181 segue na fila para o clip do popover ≤720px). Sem migration/access/Consent/schema. Intenção [`agenda-mobile-form-criacao-usavel.md`](plans/agenda-mobile-form-criacao-usavel.md); impl [`agenda-mobile-form-criacao-usavel-impl.md`](plans/agenda-mobile-form-criacao-usavel-impl.md).
|
||||
|
||||
**Recently resolved (2026-08-09):** **D6** (Issue #521) — push no device fechado de ponta a ponta. **Diagnóstico com evidência por elo** (leitura read-only de prod): (1) **envs VAPID ausentes no Vercel production** (verificado com `vercel env ls production`) — elo raiz: sem elas o servidor nunca chama o web-push e o card de perfil desabilita "Ativar"; (2) consentimento `campanha-notificacoes-push` já publicado (Onda 0, 2026-07-19); (3) 0 inscrições `pushSubscription` (bloqueado pelo elo 1); (4) 74 `Notification` geradas no sino (eventos fluem) mas **zero envios**; (5) banner no OS = passo humano (Android). **Dois defeitos de código corrigidos com evidência:** (a) o SW `/campanha/sw.js` servia `CACHE_NAME "campanha-dev"` em prod — deploys são CLI `vercel build --prebuilt` (git builds ignorados), e a rota `force-static` congelava o buildId no build onde `VERCEL_GIT_COMMIT_SHA`/`VERCEL_DEPLOYMENT_ID` não existem; a rota virou **`force-dynamic`** (lê `VERCEL_DEPLOYMENT_ID`, garantido em runtime) → cache versionado por deploy (unit test pina `dynamic`); (b) **subscribe não idempotente** — segundo toque em "Ativar neste dispositivo" no mesmo device devolvia a mesma subscription do browser e estourava o UNIQUE do `endpoint` (erro falso com o device já ativo); `subscribeCampaignPush` agora faz find-before-create e responde sucesso. **Ferramenta nova:** `pnpm db:check:push-chain` (`scripts/check-push-chain.mjs`, read-only — `PROD_DATABASE_URL` obrigatória, conexões com `default_transaction_read_only`) fecha um veredito por elo (SW servido/versionado, envs, consentimento, inscrições por usuário, log de eventos) com exit code. **Ops:** par VAPID gerado e adicionado em **Production** (`VAPID_PUBLIC_KEY`/`VAPID_PRIVATE_KEY`/`VAPID_SUBJECT`/`NEXT_PUBLIC_VAPID_PUBLIC_KEY`); preview ficou de fora — o projeto não tem Git repo conectado (deploys CLI) e `vercel env add ... preview` exige branch (`branch_not_found`); `NEXT_PUBLIC_*` precisa do build do próximo deploy para inliner. \*\*Pe...
|
||||
|
||||
**Recently resolved (2026-08-10):** **B185** (Issue #524) — o Sollinha ganhou a tool **`getPendingLeaderships`** (`src/utilities/ai/tools/getPendingLeaderships.ts`): "quais lideranças ainda precisam ser abordadas" por **território de identidade** (grafia tolerante a acento — "Vale do Jiquiriça" resolve para a região canônica), **cidade** ("Salvador" = as 19 ZE juntas) ou **município** (aliases canônicos), com o **critério de pendência sempre declarado na resposta** (`'A abordar'/'Em disputa', ou 'Engajado' sem compromisso de votos no escopo consultado` — eixo do compromisso = presença de `votePledge` liderança×município no escopo; `negativo` é excluído na query, fail-closed), lista com nome/municípios/status/última atualização/**assessores responsáveis** (nomes resolvidos em query própria — nunca `depth` populando `campaignUser`), top N (default 20, max 50) com `total` e `truncado`, escopo omitido = escopo de acesso do usuário. Filtro **C3** `sem_assessor` (`advisors: { exists: false }`, precedente B176) e modo **C4** `municipios_sem_lideranca` (municípios do escopo sem nenhuma liderança). **Gate staff inline** no topo do `execute` (leader → `{ error: 'Leitura de dados de lideranças negada.' }` antes de qualquer query, mesmo shape da B180; 1 call site — sem helper); leituras todas `overrideAccess: false, user: ctx.user` (RBAC atual). Links via `buildCampaignLinks` existente (nada mudou lá); seção nova no system prompt manda declarar o critério. Unit tests novos (`pendingLeadershipsTool.unit.spec.ts`, 14) — gate fail-closed com payload intocado, critério por status+pledge, pledge fora do escopo não satisfaz (where `municipality in`), escopos com acento/Salvador/município/erro, `sem_assessor` no where, C4, truncamento, nomes de assessores; allowlist na convenção P3-D justificada (filtro de domínio sobre set resolvido ≠ escopo de actor). Sem migration, access change ou Consent. Intenção [`sollinha-liderancas-pendentes-abordagem.md`](plans/sollinha-liderancas-pendentes-abordagem.md); impl [`sollinha-liderancas-pendentes-abordagem-impl.md`](plans/sollinha-liderancas-pendentes-abordagem-impl.md).
|
||||
|
||||
**Recently resolved (2026-08-09):** **C104** (Issue #505) — compromissos **"Todo o dia"** na agenda: o sheet de criação rápida (C91) e o formulário completo ganharam o toggle **Todo o dia** (checkbox; no sheet entre o título e Início/Término, no form no topo do card "Data e horário") — ligado, Início e Término viram **só datas** (término pode ser outro dia = faixa de vários dias inteiros; dia único = `startAt === endAt`). Representação: campo novo `allDay` (migration `20260810_010844_add_activity_all_day`) + convenção de instantes **fim inclusivo** (startAt = 1º dia 00:00 America/Bahia, endAt = último dia 00:00; Bahia sem DST); conversões exclusivo↔inclusivo (FullCalendar/iCal usam fim exclusivo) concentradas no módulo puro `lib/activityAllDay.ts` (unit-pinado: dia único, multi-dia, virada de mês/ano, rótulo "10/08/2026 a 12/08/2026"). Calendário: `allDaySlot` ligado (faixa no topo de semana/dia), `toEventInput` com `allDay`, reschedule converte os `startStr`/`endStr` exclusivos e envia `allDay`; **guard de drop**: arrastar entre a lane all-day e a grade de horário é rejeitado (revert + toast — converter por drag é feature fora do escopo). "Mais detalhes" preserva a escolha (`allDay=1` + datas civis nos params; form nasce com toggle ligado). Rótulos all-day em card, overview, dossiê do município, cabeçalho de detalhe e home search; feed iCal exporta `DTSTART;VALUE=DATE`/`DTEND;VALUE=DATE` com fim exclusivo. Liderança não consegue flipar `allDay` (snapshot staff). Sem access/Consent. Intenção [`agenda-toggle-todo-o-dia.md`](plans/agenda-toggle-todo-o-dia.md); impl [`agenda-toggle-todo-o-dia-impl.md`](plans/agenda-toggle-todo-o-dia-impl.md).
|
||||
|
|
|
|||
146
docs/plans/agenda-mobile-form-criacao-usavel-impl.md
Normal file
146
docs/plans/agenda-mobile-form-criacao-usavel-impl.md
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
# Impl: Agenda mobile — formulário de criação usável no celular (sheet do topo, rolável, salvar alcançável, seletor de data/hora em bottom sheet, form sem labels)
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-08-09
|
||||
Issue: #504
|
||||
Intenção: docs/plans/agenda-mobile-form-criacao-usavel.md
|
||||
Appetite restante: herdado (~0,5–1 dia eng)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** no celular, o sheet de criação rápida da agenda vira um formulário preenchível de ponta a ponta: sheet abre **do topo** preenchendo a altura útil; conteúdo **rola** até o fim ("Responsáveis" e "Salvar" alcançáveis em viewport pequeno e com teclado aberto); **"Salvar" fixo no rodapé** do sheet; form **sem labels visíveis** (placeholders dão contexto; obrigatórios marcados com asterisco; a11y mantida via labels ocultos); seletor de **data/hora abre como bottom sheet** no mobile (calendário + hora/minuto inteiros na tela); desktop inalterado.
|
||||
- **O que NÃO negociar:** mesmo conteúdo do C91 (anti-goal "sheet vira página"); criar não navega; salvar insere o evento no calendário (refetch da janela); leader lockdown intacto; desktop mantém o fluxo atual (popover com labels) — o clip do popover desktop (≤720px) é **B181** (#490, OPEN), fora deste item; `/nova` e `/editar` intocados; chrome do calendário mobile é **C101**; "Todo o dia" é **C104**, tags **C105**.
|
||||
- **O que reavaliar:** a hipótese da intenção cita "ajuste no `Drawer.tsx` se não couber no uso". Verificação: a primitiva **já suporta top sheet** — `swipeDirection="up"` é prop nativa (`Drawer.tsx:36`) com CSS completo (`data-[swipe-direction=up]:top-0`, closed-transform, origin, bleed; `Drawer.tsx:134,141`) e o stack/peek de nested drawers (`--nested-drawers`, `--stack-*`) já existe. **Nenhuma mudança na primitiva compartilhada** — tudo se resolve no uso (scoped à criação inline).
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[ActivityAgenda isNarrow 640px] --> B[ActivityInlineCreate]
|
||||
B -->|narrow| C[Drawer swipeDirection=up<br/>sheet do topo]
|
||||
B -->|desktop| D[Popover + PopoverAnchor<br/>inalterado]
|
||||
C --> E[ActivityInlineCreateForm variant=sheet]
|
||||
D --> F[ActivityInlineCreateForm variant=popover<br/>labels atuais, inalterado]
|
||||
E --> G[Campos sem labels<br/>placeholders + divide-y + sr-only]
|
||||
E --> H[Footer fixo shrink-0 border-t<br/>Mais detalhes + Salvar]
|
||||
G --> I[ActivityDateTimeField isNarrow]
|
||||
I -->|narrow| J[Drawer bottom sheet aninhado<br/>Calendar + Hora/Minuto + Pronto]
|
||||
I -->|desktop| K[Popover atual, inalterado]
|
||||
```
|
||||
|
||||
**Opções consideradas:** A (recomendada) | B | C
|
||||
**Recomendação:** **A** — uma variante de layout no componente existente (`variant: 'popover' | 'sheet'`) e o seletor de data/hora escolhendo contêiner (Popover vs Drawer) pela mesma prop `isNarrow` que o calendário já computa. Zero mudança na primitiva `Drawer` (já tem top sheet + nested) e zero mudança no desktop.
|
||||
|
||||
### Decisões de engenharia
|
||||
|
||||
**D1 — Sheet do topo.**
|
||||
|
||||
- Opções: A) `swipeDirection="up"` na primitiva existente | B) posicionamento custom (CSS no `DrawerContent`) | C) `Dialog` full-screen.
|
||||
- Recomendação: A — a primitiva `Drawer` implementa o top drawer completo (`Drawer.tsx:141`: `top-0`, `origin-top`, `--closed-transform` para cima, bleed inferior); passamos `swipeDirection="up"` só no uso narrow da criação inline, **backward-compatible** (default continua `down`).
|
||||
- Rejeitadas: B porque duplica CSS de animação/bleed/stack que a primitiva já tem; C porque perde o gesto de swipe (aceite "mantém o gesto de fechar").
|
||||
- Altura: `DrawerContent` recebe `className="h-[calc(100dvh-1rem)]"` — o cn merge faz `h-[…]` vencer `h-(--drawer-content-height)` (tailwind-merge trata como mesmo grupo); max-h padrão do eixo-y (`calc(100dvh-6rem)`) é sobrescrito pelo mesmo mecanismo. Smoke visual valida.
|
||||
|
||||
**D2 — Scroll do conteúdo + rodapé fixo.**
|
||||
|
||||
- Opções: A) `ActivityInlineCreateForm` ganha `variant: 'popover' | 'sheet'`; no `'sheet'` o `<form>` vira `flex min-h-0 flex-1 flex-col`, os campos vivem num scroll container (`min-h-0 flex-1 overflow-y-auto overscroll-contain`) e o footer sai para o fim do form como `shrink-0 border-t` (fixo por construção do flex) | B) render prop / estado no contêiner `ActivityInlineCreate` | C) footer no `DrawerFooter` com estado subido.
|
||||
- Recomendação: A — o footer precisa de `moreDetailsHref`, `saving` e `handleSubmit`, que vivem no estado do form; mover esse estado para o contêiner incha `ActivityInlineCreate`. O layout sheet é puramente estrutural do form (flex-col + scroll + footer), sem render prop nem fragmentos vazados.
|
||||
- Rejeitadas: B porque espalha o estado do form pelo contêiner (acoplamento sem ganho); C porque `useFormStatus` não enxerga o form irmão e `form=`/controle externo duplica o mecanismo de submit.
|
||||
- No `'popover'` o markup atual fica byte a byte: `flex flex-col gap-4`, labels visíveis, footer inline no fim.
|
||||
|
||||
**D3 — Escopo do "form sem labels".**
|
||||
|
||||
- Opções: A) só no mobile (variant `'sheet'`) | B) mobile **e** desktop (popover também sem labels).
|
||||
- Recomendação: A — o anti-goal da intenção é explícito: "não muda o desktop além do que o mesmo mecanismo de 'caber na viewport' resolver (B181)"; o aceite abre com "No celular, tocar num slot…". O desktop mantém labels e o gap atual.
|
||||
- Rejeitadas: B porque viola o anti-goal e estica o diff para o popover sem benefício de produto (a intenção é mobile).
|
||||
|
||||
**D4 — Placeholders + obrigatórios + a11y (variant sheet).**
|
||||
|
||||
- Opções: A) `FieldLabel` vira `sr-only` (mantém `htmlFor`/`aria-labelledby`, testes e leitores de tela intactos); placeholders "Adicionar título _", "Município _", "Local (opcional)", "Adicionar responsáveis"; asterisco visual nos obrigatórios | B) micro-labels flutuantes (label sobe ao digitar).
|
||||
- Recomendação: A — é a recomendação de produto da intenção (placeholders puros); `sr-only` no `FieldLabel` mantém o contrato a11y e os `getByLabelText('Título *')` dos testes existentes.
|
||||
- Rejeitadas: B porque é a opção rejeitada no plano de intenção ("o form é curto e os obrigatórios continuam marcados").
|
||||
- Detalhes por campo (variant sheet):
|
||||
- Título: placeholder `Adicionar título *`; label sr-only `Título *`.
|
||||
- Início/Término: trigger mostra o valor (prefill do slot, nunca vazio); label sr-only `Início *` / `Término`; asterisco visual discreto no trigger do Início (`ActivityDateTimeField` ganha `required?: boolean` que renderiza `*` accent ao lado do valor no narrow).
|
||||
- Município: `StrictCombobox` ganha prop opcional `placeholder?: string` (repassada ao `ComboboxInput` — backward-compatible; hoje sem placeholder) → `Município *`; label sr-only.
|
||||
- Local: placeholder `Local (opcional)` (substitui "Bairro, endereço ou referência" **só no variant sheet**); label sr-only `Local (opcional)`.
|
||||
- Responsáveis: `ResponsibleMultiSelect` ganha `labelClassName?: string` (aplicado ao `FieldLabel` interno — backward-compatible) e `emptyText?: string` para o trigger vazio (`Adicionar responsáveis`); trigger vazio hoje mostra "Nenhum" → no sheet mostra o texto vazio; label sr-only.
|
||||
- Empilhamento: no sheet, os `Field` perdem `gap-4` e ganham `divide-y divide-border` (uma linha divisória entre campos, sem respiro/card — estilo lista iOS do canvas); inputs mantêm `min-h-11` mas sem borda/rounded (overrides `border-0 bg-transparent rounded-none px-0` via className — o `Input` aceita cn merge).
|
||||
|
||||
**D5 — Seletor de data/hora como bottom sheet no mobile.**
|
||||
|
||||
- Opções: A) `ActivityDateTimeField` ganha `isNarrow: boolean`; extrai o conteúdo do popover (`Calendar` + selects Hora/Minuto) para um fragmento compartilhado e, no narrow, renderiza dentro de um `Drawer` bottom sheet (nested sobre o sheet de criação) com header "Início — <data>" + "Pronto" (fecha) | B) manter popover e ajustar colisão | C) componente novo paralelo.
|
||||
- Recomendação: A — decisão já travada no gate da intenção ("bottom sheet… mais simples de garantir visualização total"); a primitiva suporta drawer aninhado (`--nested-drawers`, peek/stack). A prop `isNarrow` vem da mesma fonte do calendário (640px), mantendo desktop=popover / mobile=sheet alinhados com a escolha do contêiner da criação.
|
||||
- Rejeitadas: B porque é exatamente o bug reportado (popover estoura a tela; hora/minuto espremidos); C porque duplica o conteúdo.
|
||||
- "Pronto" fecha o drawer; alterações de dia/hora/minuto aplicam imediatamente (mesmo contrato do popover atual). Drawer do seletor: `modal` default (overlay próprio), swipe down para fechar.
|
||||
|
||||
**D6 — Header e fechamento do sheet narrow.**
|
||||
|
||||
- Opções: A) manter `DrawerHeader` atual ("Nova atividade" + data do slot) + `showSwipeHandle` (primitiva posiciona o handle para direção up) + swipe up/backdrop para fechar | B) remover o swipe handle do top sheet (decisão do smoke) | C) adicionar botão "fechar" (X) no header.
|
||||
- Recomendação: **B (decidido no smoke visual)** — a primitiva posiciona o handle no **rodapé** de um top sheet (`order-last`), o que contradiz o gesto de fechar (swipe up empurra o sheet para cima; handle no fundo sugere swipe down). Remover `showSwipeHandle` no drawer de criação narrow; fechamento por swipe up + tap no backdrop. O handle continua nos bottom sheets (seletor de data/hora), onde o posicionamento é correto.
|
||||
- Rejeitadas: A porque o handle mal posicionado é affordance contraditória; C como v1 porque adiciona controles sem necessidade (o canvas é esquemático).
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`src/components/campaign/activity/ActivityInlineCreate.tsx`**: `ActivityInlineCreateForm` ganha `variant: 'popover' | 'sheet'` e repassa `isNarrow` ao `ActivityDateTimeField`; narrow renderiza `Drawer swipeDirection="up"` (sem swipe handle — decidido no smoke, D6) + `DrawerContent className="h-[calc(100dvh-1rem)]"` com `DrawerHeader` fixo, `<form variant="sheet">` (scroll interno + footer fixo) sem o wrapper `px-4 pb-6` (o scroll container assume o padding). Desktop visualmente inalterado.
|
||||
- **`src/components/campaign/activity/ActivityDateTimeField.tsx`**: prop `isNarrow` + `required?`; conteúdo do picker (Calendar + selects) extraído para fragmento local; narrow → `Drawer` bottom sheet com header ("Início/Término — <data>" + "Pronto") e corpo com o conteúdo compartilhado; desktop → `Popover` atual. Focus visible mantido no narrow (`focus-visible:ring-2`), `aria-haspopup="dialog"`/`aria-expanded` no trigger narrow, `onClick` só no narrow (no desktop o Radix toggla).
|
||||
- **`src/components/campaign/shared/StrictCombobox.tsx`**: prop opcional `placeholder` e `className` repassados ao `ComboboxInput` (backward-compatible).
|
||||
- **`src/components/campaign/shared/ResponsibleMultiSelect.tsx`**: props opcionais `labelClassName` (no `FieldLabel`), `emptyText` (texto do trigger vazio, no lugar de "Nenhum") e `triggerClassName` (backward-compatible).
|
||||
- **`src/components/ui/`**: sem mudanças (`Drawer` já cobre `swipeDirection="up"` + nested; `Input`/`FieldLabel` aceitam className).
|
||||
- **Migration:** sem migration (sem schema).
|
||||
- **Access / Consent:** nenhum toque (mesma action `createActivityInline`, mesmo access).
|
||||
- **UI:** Impeccable B — encaixe na superfície existente (mesma convenção do C97). shape → craft (sem labels, divide-y, rodapé fixo, seletor em sheet) → critique → polish. Validação visual no dev browser (viewport ~390×844 e 640×480, teclado aberto).
|
||||
|
||||
### Dados → forma
|
||||
|
||||
N/A — affordance de escrita; a intenção declara `Dados: N/A`.
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Tracer (quota pequena)** — `variant` no form + `swipeDirection="up"` + scroll/footer fixo: smoke no dev narrow: sheet desce do topo, conteúdo rola até "Responsáveis", "Salvar" fixo, teclado aberto não esconde o rodapé.
|
||||
2. **Form sem labels (variant sheet)** — placeholders, `sr-only` labels, asteriscos, `divide-y`, overrides de input; props `placeholder`/`labelClassName` em `StrictCombobox`/`ResponsibleMultiSelect`; smoke visual contra o canvas.
|
||||
3. **Seletor de data/hora narrow** — extração do conteúdo, `Drawer` bottom sheet aninhado com "Pronto"; desktop continua popover (smoke dos dois).
|
||||
4. **Testes** — unit atualizados + novos (abaixo); e2e mobile novo + desktop intacto.
|
||||
5. **Gates** — `pnpm gate:fast` na iteração; `pnpm test:int`; e2e afetado (`pnpm test:e2e` agenda); `pnpm format:check`, `pnpm exec knip`, `pnpm check:cycles`; `pnpm build` (DB local); scan Aikido dos arquivos tocados; registro no CHANGELOG-AGENTS; `pnpm push` → PR `--base main` `Closes #504` → auto-merge → `gh pr checks --watch --required`.
|
||||
|
||||
### Testes
|
||||
|
||||
- **Unit** (`tests/unit/activityInlineCreate.unit.spec.tsx`):
|
||||
- Narrow: `getByLabelText('Título *')` continua resolvendo (label sr-only); placeholder do título é `Adicionar título *`; "Salvar" presente no rodapé do sheet; campos separados por divisórias (asserção por classe/estrutura se viável).
|
||||
- Narrow: abrir Início abre **drawer** (role dialog do seletor com "Pronto"), não popover; "Pronto" fecha.
|
||||
- Desktop: testes existentes inalterados (labels visíveis).
|
||||
- **E2E** (`tests/e2e/campaignActivity.e2e.spec.ts`):
|
||||
- Novo teste mobile (`test.use({ viewport: { width: 390, height: 844 } })`, precedente `campaignBottomNav.e2e.spec.ts`): abrir slot → sheet do topo → preencher título + município → "Salvar" visível sem scroll → salvar → evento aparece na agenda.
|
||||
- Testes desktop existentes intactos (labels continuam no popover).
|
||||
- **Unit helpers**: nenhum helper novo puro (nada em `campaignTime`/`activityUi` muda).
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- **Mudar a primitiva `Drawer`** para "suportar" top sheet — já suporta; mexer nela é risco de blast radius (CalendarFeedDialog, bottom nav, listas).
|
||||
- **`/nova` e `/editar`** (`ActivityForm.tsx`/`ActivityTaskFields.tsx`): labels/placeholders/seletor ficam como estão.
|
||||
- **B181** (clip do popover desktop ≤720px): fora — Issue #490 OPEN.
|
||||
- **C101/C104/C105**: chrome do calendário mobile / all-day / tags — fora.
|
||||
- **Labels flutuantes / steppers de hora / range no calendário**: cortes de produto já travados na intenção.
|
||||
- **`generate:importmap`**: não se aplica (componentes de app, não de admin).
|
||||
- **Micro-interação do handle no top sheet**: se o handle ficar mal posicionado, follow-up (não estica o v1).
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Nested drawer com direções diferentes (criação `up` + seletor `down`):** o stack/peek da primitiva usa vars compartilhadas (`--nested-drawers`, `--stack-*`) independentes da direção; validar no dev browser antes do e2e. Se quebrar visualmente, fallback: seletor também `swipeDirection="up"` (mesma direção) — mudança contida no uso, sem tocar a primitiva.
|
||||
- **Override `h-[calc(100dvh-1rem)]` vs `h-(--drawer-content-height)`:** tailwind-merge trata como mesmo grupo (height) — verificar no smoke; alternativa: `style={{ '--drawer-content-height': 'calc(100dvh-1rem)' }}` sem mudar a primitiva.
|
||||
- **`getByLabelText` com label `sr-only`:** `FieldLabel` mantém `htmlFor` — `getByLabelText` resolve por associação; coberto nos unit.
|
||||
- **Combobox/ResponsibleMultiSelect com props novas:** default preserva o comportamento atual (placeholder ausente → sem placeholder; `labelClassName` vazio → classes atuais).
|
||||
- **e2e mobile do FullCalendar (lazy render do grid):** copiar o padrão do teste desktop (`Carregando compromissos…` até sumir + bounding box do slot).
|
||||
|
||||
## Follow-ups registrados (fora da fatia mínima)
|
||||
|
||||
- **Clip do popover desktop ≤720px** → B181 (#490), já na fila.
|
||||
- **Uniformizar `ActivityDateTimeField` no form completo/tarefas** (registrado no impl do C97).
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto da intenção coberto: sheet do topo no mobile, conteúdo rola até o fim, "Salvar" fixo, sem labels visíveis (placeholders + asteriscos + a11y sr-only), seletor de data/hora em bottom sheet no mobile, desktop inalterado, criar não navega, leader lockdown intacto
|
||||
- [ ] Invariantes AGENTS/engineering-standards (sem schema/access/Consent; identificadores em inglês; copy pt-BR; `Drawer` primitiva compartilhada intocada)
|
||||
- [ ] Testes de domínio previstos (unit narrow/desktop + e2e mobile novo; e2e desktop intacto)
|
||||
- [ ] `model-local: deepseek-v4-flash-high` já declarado no plano de intenção (mapping canônico de `model: composer-2.5`)
|
||||
|
||||
Self-score decision-quality: 5/5 — decisões caras com rejeitadas (D1–D6), abordagem no appetite, rabbit holes nomeados, depth check (reusa primitiva + componentes, sem twins), intenção satisfeita.
|
||||
|
|
@ -1,10 +1,18 @@
|
|||
'use client'
|
||||
|
||||
import { CalendarIcon } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { ptBR } from 'react-day-picker/locale'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Calendar } from '@/components/ui/Calendar'
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from '@/components/ui/Drawer'
|
||||
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
|
||||
import {
|
||||
|
|
@ -27,10 +35,12 @@ import { cn } from '@/lib/utils'
|
|||
* The parent renders the surrounding Field/FieldError; this component is the
|
||||
* control itself (trigger + popover with calendar and step selects).
|
||||
*
|
||||
* C103 — on narrow viewports the picker opens as a nested bottom sheet
|
||||
* (calendar + hour/minute fully on screen, "Pronto" applies and closes),
|
||||
* keeping the desktop popover untouched.
|
||||
*
|
||||
* C104 — `timeVisible={false}` is the all-day mode: the trigger formats only
|
||||
* the date and the popover renders just the calendar (no time selects); the
|
||||
* value contract stays the same civil string, so the date part is the
|
||||
* all-day day and the time part is ignored on submit.
|
||||
* the date and the picker renders just the calendar (no time selects).
|
||||
*/
|
||||
export const ActivityDateTimeField = ({
|
||||
id,
|
||||
|
|
@ -38,6 +48,9 @@ export const ActivityDateTimeField = ({
|
|||
onValueChange,
|
||||
invalid = false,
|
||||
errorId,
|
||||
isNarrow = false,
|
||||
label,
|
||||
required = false,
|
||||
timeVisible = true,
|
||||
}: {
|
||||
id: string
|
||||
|
|
@ -46,9 +59,16 @@ export const ActivityDateTimeField = ({
|
|||
invalid?: boolean
|
||||
/** FieldError id to announce on focus — parity with the input it replaces. */
|
||||
errorId?: string
|
||||
/** Renders the picker as a nested bottom sheet instead of a popover. */
|
||||
isNarrow?: boolean
|
||||
/** Field name used in the sheet header (e.g. "Início"). */
|
||||
label?: string
|
||||
/** Marks the required fields with a visible asterisk on the narrow trigger. */
|
||||
required?: boolean
|
||||
/** When false, hide the time selects and label the trigger with the date only. */
|
||||
timeVisible?: boolean
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const civil = floorToMinuteStep(value)
|
||||
const datePart = civil.slice(0, 10)
|
||||
const timePart = civil.slice(11)
|
||||
|
|
@ -65,81 +85,123 @@ export const ActivityDateTimeField = ({
|
|||
|
||||
const selectedInstant = parseBahiaDateTimeInput(`${datePart}T12:00`)
|
||||
|
||||
const trigger = (
|
||||
<Button
|
||||
id={id}
|
||||
type="button"
|
||||
variant="outline"
|
||||
aria-invalid={invalid}
|
||||
aria-describedby={errorId}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={isNarrow ? open : undefined}
|
||||
onClick={isNarrow ? () => setOpen(true) : undefined}
|
||||
className={cn(
|
||||
'min-h-11 w-full justify-between rounded-full border-(--field-border) bg-(--field-background) px-4 text-base text-(--field-foreground) hover:bg-(--field-background) hover:text-(--field-foreground) data-[state=open]:border-primary focus-visible:border-primary focus-visible:ring-3 focus-visible:ring-primary/30 md:text-sm',
|
||||
isNarrow &&
|
||||
'rounded-none border-0 bg-transparent px-0 hover:bg-transparent focus-visible:ring-2 focus-visible:ring-primary/30',
|
||||
invalid &&
|
||||
'border-destructive ring-3 ring-destructive/20 focus-visible:border-destructive focus-visible:ring-destructive/20',
|
||||
)}
|
||||
>
|
||||
<span className="truncate">
|
||||
{timeVisible ? formatBahiaCivilDateTimeLabel(civil) : formatBahiaCivilDateLabel(civil)}
|
||||
</span>
|
||||
{isNarrow && required ? (
|
||||
<span aria-hidden="true" className="font-semibold text-destructive">
|
||||
*
|
||||
</span>
|
||||
) : null}
|
||||
<CalendarIcon
|
||||
data-icon="inline-end"
|
||||
aria-hidden="true"
|
||||
className="size-4 text-(--field-icon)"
|
||||
/>
|
||||
</Button>
|
||||
)
|
||||
|
||||
const pickerContent = (
|
||||
<>
|
||||
{selectedInstant ? (
|
||||
<Calendar
|
||||
mode="single"
|
||||
locale={ptBR}
|
||||
timeZone="America/Bahia"
|
||||
selected={new Date(selectedInstant)}
|
||||
onSelect={setDate}
|
||||
className="w-full p-2"
|
||||
/>
|
||||
) : null}
|
||||
{timeVisible ? (
|
||||
<div className="flex flex-col gap-3 border-t p-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs font-medium text-muted-foreground select-none">Hora</span>
|
||||
<NativeSelect
|
||||
aria-label="Hora"
|
||||
value={hour}
|
||||
onChange={(event) => setHour(event.target.value)}
|
||||
>
|
||||
{hourOptions.map((option) => (
|
||||
<NativeSelectOption key={option} value={option}>
|
||||
{option}h
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs font-medium text-muted-foreground select-none">Minuto</span>
|
||||
<NativeSelect
|
||||
aria-label="Minuto"
|
||||
value={minute}
|
||||
onChange={(event) => setMinute(event.target.value)}
|
||||
>
|
||||
{minuteOptionsForStep().map((option) => (
|
||||
<NativeSelectOption key={option} value={option}>
|
||||
{option}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
|
||||
if (isNarrow) {
|
||||
return (
|
||||
<>
|
||||
{trigger}
|
||||
<Drawer open={open} onOpenChange={setOpen} showSwipeHandle>
|
||||
<DrawerContent>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>
|
||||
{label ? `${label} — ` : ''}
|
||||
{selectedInstant ? formatBahiaCivilDate(new Date(selectedInstant)) : datePart}
|
||||
</DrawerTitle>
|
||||
</DrawerHeader>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-2 pb-2">
|
||||
{pickerContent}
|
||||
</div>
|
||||
<DrawerFooter className="border-t">
|
||||
<Button type="button" className="min-h-11 w-full" onClick={() => setOpen(false)}>
|
||||
Pronto
|
||||
</Button>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
id={id}
|
||||
type="button"
|
||||
variant="outline"
|
||||
aria-invalid={invalid}
|
||||
aria-describedby={errorId}
|
||||
className={cn(
|
||||
'min-h-11 w-full justify-between rounded-full border-(--field-border) bg-(--field-background) px-4 text-base text-(--field-foreground) hover:bg-(--field-background) hover:text-(--field-foreground) data-[state=open]:border-primary focus-visible:border-primary focus-visible:ring-3 focus-visible:ring-primary/30 md:text-sm',
|
||||
invalid &&
|
||||
'border-destructive ring-3 ring-destructive/20 focus-visible:border-destructive focus-visible:ring-destructive/20',
|
||||
)}
|
||||
>
|
||||
<span className="truncate">
|
||||
{timeVisible ? formatBahiaCivilDateTimeLabel(civil) : formatBahiaCivilDateLabel(civil)}
|
||||
</span>
|
||||
<CalendarIcon
|
||||
data-icon="inline-end"
|
||||
aria-hidden="true"
|
||||
className="size-4 text-(--field-icon)"
|
||||
/>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="max-h-[22rem] w-80 overflow-y-auto p-0 overscroll-contain"
|
||||
>
|
||||
{selectedInstant ? (
|
||||
<Calendar
|
||||
mode="single"
|
||||
locale={ptBR}
|
||||
timeZone="America/Bahia"
|
||||
selected={new Date(selectedInstant)}
|
||||
onSelect={setDate}
|
||||
className="w-full p-2"
|
||||
/>
|
||||
) : null}
|
||||
{timeVisible ? (
|
||||
<div className="flex flex-col gap-3 border-t p-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs font-medium text-muted-foreground select-none">Hora</span>
|
||||
<NativeSelect
|
||||
aria-label="Hora"
|
||||
value={hour}
|
||||
onChange={(event) => setHour(event.target.value)}
|
||||
>
|
||||
{hourOptions.map((option) => (
|
||||
<NativeSelectOption key={option} value={option}>
|
||||
{option}h
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs font-medium text-muted-foreground select-none">
|
||||
Minuto
|
||||
</span>
|
||||
<NativeSelect
|
||||
aria-label="Minuto"
|
||||
value={minute}
|
||||
onChange={(event) => setMinute(event.target.value)}
|
||||
>
|
||||
{minuteOptionsForStep().map((option) => (
|
||||
<NativeSelectOption key={option} value={option}>
|
||||
{option}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{pickerContent}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -40,6 +40,11 @@ import type { StrictComboboxOption } from '@/utilities/territory/territoryCombob
|
|||
* One content component is rendered inside a Popover (desktop, anchored at the
|
||||
* click) or a bottom Drawer (mobile); saving never navigates and the parent
|
||||
* refetches the visible window (`onCreated`).
|
||||
*
|
||||
* C103 — on narrow viewports the drawer opens from the TOP (`swipeDirection="up"`)
|
||||
* filling the usable height, the form switches to a label-less list variant
|
||||
* (placeholders + divider rows, `sr-only` labels) with a fixed footer, and the
|
||||
* date/time picker opens as a nested bottom sheet.
|
||||
*/
|
||||
|
||||
export type ActivityInlineCreateDraft = {
|
||||
|
|
@ -96,12 +101,64 @@ const parseInlineResponsibles = (
|
|||
return entries.length > 0 ? entries : undefined
|
||||
}
|
||||
|
||||
/** List-row styling for the label-less mobile sheet (C103). The focus ring is
|
||||
* kept (subtle) so keyboard users keep a visible focus indicator. */
|
||||
const sheetFieldInputClass =
|
||||
'rounded-none border-0 bg-transparent px-0 py-0 focus-visible:ring-2 focus-visible:ring-primary/30'
|
||||
|
||||
type InlineDateTimeFieldProps = {
|
||||
sheet: boolean
|
||||
id: string
|
||||
label: string
|
||||
value: string
|
||||
onValueChange: (value: string) => void
|
||||
invalid: boolean
|
||||
error?: string
|
||||
required?: boolean
|
||||
/** C104 — false hides the time selects (all-day mode). */
|
||||
timeVisible?: boolean
|
||||
}
|
||||
|
||||
const InlineDateTimeField = ({
|
||||
sheet,
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
onValueChange,
|
||||
invalid,
|
||||
error,
|
||||
required = false,
|
||||
timeVisible = true,
|
||||
}: InlineDateTimeFieldProps) => (
|
||||
<Field data-invalid={invalid}>
|
||||
<FieldLabel htmlFor={id} className={sheet ? 'sr-only' : undefined}>
|
||||
{label}
|
||||
{required ? ' *' : ''}
|
||||
</FieldLabel>
|
||||
<ActivityDateTimeField
|
||||
id={id}
|
||||
value={value}
|
||||
onValueChange={onValueChange}
|
||||
invalid={invalid}
|
||||
errorId={error ? `${id}-error` : undefined}
|
||||
// The sheet variant implies the narrow viewport (C103); the picker keeps
|
||||
// the popover whenever the create overlay is a popover.
|
||||
isNarrow={sheet}
|
||||
label={label}
|
||||
required={required}
|
||||
timeVisible={timeVisible}
|
||||
/>
|
||||
{error ? <FieldError id={`${id}-error`}>{error}</FieldError> : null}
|
||||
</Field>
|
||||
)
|
||||
|
||||
const ActivityInlineCreateForm = ({
|
||||
startAt,
|
||||
endAt,
|
||||
agendaState,
|
||||
municipalityOptions,
|
||||
knownTags,
|
||||
variant,
|
||||
onCreated,
|
||||
}: {
|
||||
startAt: string
|
||||
|
|
@ -109,8 +166,10 @@ const ActivityInlineCreateForm = ({
|
|||
agendaState: ActivityAgendaState
|
||||
municipalityOptions: RelationOption[]
|
||||
knownTags?: string[]
|
||||
variant: 'popover' | 'sheet'
|
||||
onCreated: () => void
|
||||
}) => {
|
||||
const sheet = variant === 'sheet'
|
||||
const [title, setTitle] = useState('')
|
||||
const [start, setStart] = useState(formatIsoAsBahiaDateTimeInput(startAt))
|
||||
const [end, setEnd] = useState(formatIsoAsBahiaDateTimeInput(endAt))
|
||||
|
|
@ -219,113 +278,174 @@ const ActivityInlineCreateForm = ({
|
|||
}
|
||||
}
|
||||
|
||||
const startEndFields = (
|
||||
<>
|
||||
<InlineDateTimeField
|
||||
sheet={sheet}
|
||||
id="inline-startAt"
|
||||
label="Início"
|
||||
value={start}
|
||||
onValueChange={setStart}
|
||||
invalid={Boolean(errorFor('startAt'))}
|
||||
error={errorFor('startAt')}
|
||||
required
|
||||
timeVisible={!allDay}
|
||||
/>
|
||||
<InlineDateTimeField
|
||||
sheet={sheet}
|
||||
id="inline-endAt"
|
||||
label="Término"
|
||||
value={end}
|
||||
onValueChange={setEnd}
|
||||
invalid={Boolean(errorFor('endAt'))}
|
||||
error={errorFor('endAt')}
|
||||
timeVisible={!allDay}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} noValidate className="flex flex-col gap-4">
|
||||
<Field data-invalid={Boolean(errorFor('title'))}>
|
||||
<FieldLabel htmlFor="inline-title">Título *</FieldLabel>
|
||||
<Input
|
||||
id="inline-title"
|
||||
value={title}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
maxLength={160}
|
||||
minLength={2}
|
||||
placeholder="Ex.: Café com apoiadores"
|
||||
className="min-h-11"
|
||||
autoComplete="off"
|
||||
aria-invalid={Boolean(errorFor('title'))}
|
||||
aria-describedby={errorFor('title') ? 'inline-title-error' : undefined}
|
||||
/>
|
||||
{errorFor('title') ? (
|
||||
<FieldError id="inline-title-error">{errorFor('title')}</FieldError>
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
noValidate
|
||||
className={sheet ? 'flex min-h-0 flex-1 flex-col' : 'flex flex-col gap-4'}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
sheet
|
||||
? 'min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 pb-6'
|
||||
: 'flex flex-col gap-4'
|
||||
}
|
||||
>
|
||||
<div className={sheet ? 'divide-y divide-border' : undefined}>
|
||||
<Field data-invalid={Boolean(errorFor('title'))}>
|
||||
<FieldLabel htmlFor="inline-title" className={sheet ? 'sr-only' : undefined}>
|
||||
Título *
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="inline-title"
|
||||
value={title}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
maxLength={160}
|
||||
minLength={2}
|
||||
placeholder={sheet ? 'Adicionar título *' : 'Ex.: Café com apoiadores'}
|
||||
className={sheet ? `min-h-11 ${sheetFieldInputClass}` : 'min-h-11'}
|
||||
autoComplete="off"
|
||||
aria-invalid={Boolean(errorFor('title'))}
|
||||
aria-describedby={errorFor('title') ? 'inline-title-error' : undefined}
|
||||
/>
|
||||
{errorFor('title') ? (
|
||||
<FieldError id="inline-title-error">{errorFor('title')}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
|
||||
{/* C104 — all-day toggle: hides the time part of Início/Término. */}
|
||||
<Field
|
||||
orientation="horizontal"
|
||||
className={
|
||||
sheet ? 'min-h-11 gap-2 rounded-none border-0 px-0' : 'min-h-11 rounded-lg border p-3'
|
||||
}
|
||||
>
|
||||
<Checkbox
|
||||
checked={allDay}
|
||||
onCheckedChange={(next) => setAllDay(Boolean(next))}
|
||||
aria-label="Todo o dia"
|
||||
/>
|
||||
<span>Todo o dia</span>
|
||||
</Field>
|
||||
|
||||
{sheet ? (
|
||||
startEndFields
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2">{startEndFields}</div>
|
||||
)}
|
||||
|
||||
<Field data-invalid={Boolean(errorFor('municipality'))}>
|
||||
<FieldLabel htmlFor="inline-municipality" className={sheet ? 'sr-only' : undefined}>
|
||||
Município *
|
||||
</FieldLabel>
|
||||
<StrictCombobox
|
||||
id="inline-municipality"
|
||||
options={inlineMunicipalityOptions(municipalityOptions)}
|
||||
value={municipalityValue}
|
||||
onValueChange={setMunicipalityValue}
|
||||
error={errorFor('municipality')}
|
||||
placeholder={sheet ? 'Município *' : undefined}
|
||||
className={sheet ? 'min-h-11 rounded-none border-0' : undefined}
|
||||
/>
|
||||
{errorFor('municipality') ? (
|
||||
<FieldError id="inline-municipality-error">{errorFor('municipality')}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="inline-locality" className={sheet ? 'sr-only' : undefined}>
|
||||
Local (opcional)
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="inline-locality"
|
||||
value={locality}
|
||||
onChange={(event) => setLocality(event.target.value)}
|
||||
maxLength={160}
|
||||
placeholder={sheet ? 'Local (opcional)' : 'Bairro, endereço ou referência'}
|
||||
className={sheet ? `min-h-11 ${sheetFieldInputClass}` : 'min-h-11'}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="inline-locality" className={sheet ? 'sr-only' : undefined}>
|
||||
Local (opcional)
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="inline-locality"
|
||||
value={locality}
|
||||
onChange={(event) => setLocality(event.target.value)}
|
||||
maxLength={160}
|
||||
placeholder={sheet ? 'Local (opcional)' : 'Bairro, endereço ou referência'}
|
||||
className={sheet ? `min-h-11 ${sheetFieldInputClass}` : 'min-h-11'}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className={sheet ? 'py-1.5' : undefined}>
|
||||
<Field>
|
||||
<FieldLabel className={sheet ? 'sr-only' : undefined}>Tags</FieldLabel>
|
||||
<ActivityTagInput
|
||||
knownTags={knownTags}
|
||||
onChange={setTags}
|
||||
compact={sheet}
|
||||
placeholder={sheet ? 'Adicionar tags' : undefined}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<ResponsibleMultiSelect
|
||||
name="responsiblesJson"
|
||||
label="Responsáveis"
|
||||
search={searchActivityResponsibleOptionsAction}
|
||||
labelClassName={sheet ? 'sr-only' : undefined}
|
||||
emptyText={sheet ? 'Adicionar responsáveis' : undefined}
|
||||
triggerClassName={
|
||||
sheet
|
||||
? 'rounded-none border-0 bg-transparent px-0 hover:bg-transparent focus-visible:ring-2 focus-visible:ring-primary/30'
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{failedMessage ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{failedMessage}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<Field orientation="horizontal" className="min-h-11 rounded-lg border p-3">
|
||||
<Checkbox
|
||||
checked={allDay}
|
||||
onCheckedChange={(next) => setAllDay(Boolean(next))}
|
||||
aria-label="Todo o dia"
|
||||
/>
|
||||
<span>Todo o dia</span>
|
||||
</Field>
|
||||
</Field>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Field data-invalid={Boolean(errorFor('startAt'))}>
|
||||
<FieldLabel htmlFor="inline-startAt">Início *</FieldLabel>
|
||||
<ActivityDateTimeField
|
||||
id="inline-startAt"
|
||||
value={start}
|
||||
onValueChange={setStart}
|
||||
invalid={Boolean(errorFor('startAt'))}
|
||||
errorId={errorFor('startAt') ? 'inline-startAt-error' : undefined}
|
||||
timeVisible={!allDay}
|
||||
/>
|
||||
{errorFor('startAt') ? (
|
||||
<FieldError id="inline-startAt-error">{errorFor('startAt')}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
<Field data-invalid={Boolean(errorFor('endAt'))}>
|
||||
<FieldLabel htmlFor="inline-endAt">Término</FieldLabel>
|
||||
<ActivityDateTimeField
|
||||
id="inline-endAt"
|
||||
value={end}
|
||||
onValueChange={setEnd}
|
||||
invalid={Boolean(errorFor('endAt'))}
|
||||
errorId={errorFor('endAt') ? 'inline-endAt-error' : undefined}
|
||||
timeVisible={!allDay}
|
||||
/>
|
||||
{errorFor('endAt') ? (
|
||||
<FieldError id="inline-endAt-error">{errorFor('endAt')}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field data-invalid={Boolean(errorFor('municipality'))}>
|
||||
<FieldLabel htmlFor="inline-municipality">Município *</FieldLabel>
|
||||
<StrictCombobox
|
||||
id="inline-municipality"
|
||||
options={inlineMunicipalityOptions(municipalityOptions)}
|
||||
value={municipalityValue}
|
||||
onValueChange={setMunicipalityValue}
|
||||
error={errorFor('municipality')}
|
||||
/>
|
||||
{errorFor('municipality') ? (
|
||||
<FieldError id="inline-municipality-error">{errorFor('municipality')}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="inline-locality">Local (opcional)</FieldLabel>
|
||||
<Input
|
||||
id="inline-locality"
|
||||
value={locality}
|
||||
onChange={(event) => setLocality(event.target.value)}
|
||||
maxLength={160}
|
||||
placeholder="Bairro, endereço ou referência"
|
||||
className="min-h-11"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Tags</FieldLabel>
|
||||
<ActivityTagInput knownTags={knownTags} onChange={setTags} />
|
||||
</Field>
|
||||
|
||||
<ResponsibleMultiSelect
|
||||
name="responsiblesJson"
|
||||
label="Responsáveis"
|
||||
search={searchActivityResponsibleOptionsAction}
|
||||
/>
|
||||
|
||||
{failedMessage ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{failedMessage}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<div className="mt-1 flex items-center justify-between gap-3 border-t pt-3">
|
||||
<div
|
||||
className={
|
||||
sheet
|
||||
? 'flex shrink-0 items-center justify-between gap-3 border-t px-4 py-3'
|
||||
: 'mt-1 flex items-center justify-between gap-3 border-t pt-3'
|
||||
}
|
||||
>
|
||||
<Button asChild variant="ghost" className="min-h-11">
|
||||
<Link href={moreDetailsHref}>Mais detalhes</Link>
|
||||
</Button>
|
||||
|
|
@ -349,26 +469,23 @@ export const ActivityInlineCreate = ({
|
|||
}: ActivityInlineCreateProps) => {
|
||||
if (!draft) return null
|
||||
|
||||
const form = (
|
||||
<ActivityInlineCreateForm
|
||||
startAt={draft.startAt}
|
||||
endAt={draft.endAt}
|
||||
agendaState={agendaState}
|
||||
municipalityOptions={municipalityOptions}
|
||||
knownTags={knownTags}
|
||||
onCreated={onCreated}
|
||||
/>
|
||||
)
|
||||
|
||||
if (isNarrow) {
|
||||
return (
|
||||
<Drawer open onOpenChange={(next) => (next ? undefined : onClose())} showSwipeHandle>
|
||||
<DrawerContent>
|
||||
<Drawer open onOpenChange={(next) => (next ? undefined : onClose())} swipeDirection="up">
|
||||
<DrawerContent className="h-[calc(100dvh-1rem)] max-h-[calc(100dvh-1rem)]">
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>Nova atividade</DrawerTitle>
|
||||
<DrawerDescription>{formatBahiaDateTimeLabel(draft.startAt)}</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<div className="px-4 pb-6">{form}</div>
|
||||
<ActivityInlineCreateForm
|
||||
startAt={draft.startAt}
|
||||
endAt={draft.endAt}
|
||||
agendaState={agendaState}
|
||||
municipalityOptions={municipalityOptions}
|
||||
knownTags={knownTags}
|
||||
variant="sheet"
|
||||
onCreated={onCreated}
|
||||
/>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
|
|
@ -395,7 +512,15 @@ export const ActivityInlineCreate = ({
|
|||
sideOffset={8}
|
||||
className="max-h-(--radix-popper-available-height) w-96 overflow-y-auto p-4"
|
||||
>
|
||||
{form}
|
||||
<ActivityInlineCreateForm
|
||||
startAt={draft.startAt}
|
||||
endAt={draft.endAt}
|
||||
agendaState={agendaState}
|
||||
municipalityOptions={municipalityOptions}
|
||||
knownTags={knownTags}
|
||||
variant="popover"
|
||||
onCreated={onCreated}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -20,11 +20,17 @@ export const ActivityTagInput = ({
|
|||
knownTags = [],
|
||||
error,
|
||||
onChange,
|
||||
compact = false,
|
||||
placeholder,
|
||||
}: {
|
||||
initialTags?: string[]
|
||||
knownTags?: string[]
|
||||
error?: string
|
||||
onChange?: (tags: string[]) => void
|
||||
/** Label-less list-row variant (C103 mobile sheet): borderless input, no description. */
|
||||
compact?: boolean
|
||||
/** Input placeholder override (defaults to the C105 copy). */
|
||||
placeholder?: string
|
||||
}) => {
|
||||
const [tags, setTags] = useState<string[]>(initialTags)
|
||||
const [input, setInput] = useState('')
|
||||
|
|
@ -88,8 +94,14 @@ export const ActivityTagInput = ({
|
|||
}
|
||||
}}
|
||||
onBlur={() => addTag(input)}
|
||||
placeholder={tags.length === 0 ? 'Ex.: comício, imprensa…' : 'Adicionar tag…'}
|
||||
className="min-h-9 flex-1"
|
||||
placeholder={
|
||||
placeholder ?? (tags.length === 0 ? 'Ex.: comício, imprensa…' : 'Adicionar tag…')
|
||||
}
|
||||
className={
|
||||
compact
|
||||
? 'min-h-11 flex-1 rounded-none border-0 bg-transparent px-0 focus-visible:ring-2 focus-visible:ring-primary/30'
|
||||
: 'min-h-9 flex-1'
|
||||
}
|
||||
list={datalistId}
|
||||
maxLength={MAX_ACTIVITY_TAG_LENGTH}
|
||||
aria-label="Adicionar tag"
|
||||
|
|
@ -104,9 +116,11 @@ export const ActivityTagInput = ({
|
|||
</datalist>
|
||||
</div>
|
||||
<input type="hidden" name="tagsJson" value={JSON.stringify(tags)} />
|
||||
<FieldDescription>
|
||||
Classificação livre do compromisso. Digite e pressione Enter ou vírgula para adicionar.
|
||||
</FieldDescription>
|
||||
{compact ? null : (
|
||||
<FieldDescription>
|
||||
Classificação livre do compromisso. Digite e pressione Enter ou vírgula para adicionar.
|
||||
</FieldDescription>
|
||||
)}
|
||||
{error ? <FieldError>{error}</FieldError> : null}
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import {
|
|||
MAX_ACTIVITY_RESPONSIBLES,
|
||||
type ActivityResponsibleCollection,
|
||||
} from '@/lib/schemas/activity'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type ResponsibleOption = {
|
||||
relationTo: ActivityResponsibleCollection
|
||||
|
|
@ -35,6 +36,12 @@ type ResponsibleMultiSelectProps = {
|
|||
value?: ResponsibleOption[]
|
||||
error?: string
|
||||
description?: string
|
||||
/** Extra classes for the field label (e.g. `sr-only` on label-less sheets). */
|
||||
labelClassName?: string
|
||||
/** Trigger text while empty (replaces the "Nenhum" fallback). */
|
||||
emptyText?: string
|
||||
/** Extra classes for the trigger button (e.g. borderless list rows). */
|
||||
triggerClassName?: string
|
||||
search: (query: string) => Promise<ResponsibleOption[]>
|
||||
}
|
||||
|
||||
|
|
@ -49,6 +56,9 @@ export const ResponsibleMultiSelect = ({
|
|||
value = [],
|
||||
error,
|
||||
description,
|
||||
labelClassName,
|
||||
emptyText,
|
||||
triggerClassName,
|
||||
search,
|
||||
}: ResponsibleMultiSelectProps) => {
|
||||
// Initial-only (like RelationMultiSelect's initialSelectedIDs): on the create
|
||||
|
|
@ -124,12 +134,12 @@ export const ResponsibleMultiSelect = ({
|
|||
|
||||
const selectedLabel =
|
||||
selected.length === 0
|
||||
? 'Nenhum'
|
||||
? (emptyText ?? 'Nenhum')
|
||||
: `${selected.length} ${selected.length === 1 ? 'responsável' : 'responsáveis'}`
|
||||
|
||||
return (
|
||||
<Field>
|
||||
<FieldLabel>{label}</FieldLabel>
|
||||
<FieldLabel className={labelClassName}>{label}</FieldLabel>
|
||||
<input
|
||||
type="hidden"
|
||||
name={name}
|
||||
|
|
@ -158,7 +168,7 @@ export const ResponsibleMultiSelect = ({
|
|||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="min-h-11 w-full justify-between font-normal"
|
||||
className={cn('min-h-11 w-full justify-between font-normal', triggerClassName)}
|
||||
aria-label={`${label}: ${selectedLabel}`}
|
||||
aria-invalid={error ? true : undefined}
|
||||
aria-describedby={error ? `${name}-error` : undefined}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ type StrictComboboxProps = {
|
|||
onInvalid?: () => void
|
||||
error?: string
|
||||
placeholder?: string
|
||||
/** Extra classes for the input group (e.g. label-less list rows). */
|
||||
className?: string
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -428,3 +428,62 @@ test.describe('Agenda — calendário operacional', () => {
|
|||
await expect(page.getByRole('grid').first()).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Atividades — agenda mobile (C103)', () => {
|
||||
test.setTimeout(90_000)
|
||||
test.use({ viewport: { width: 390, height: 844 } })
|
||||
|
||||
test('cria compromisso inline pelo sheet do topo, rodapé fixo e seletor em bottom sheet', async ({
|
||||
campaign,
|
||||
page,
|
||||
}) => {
|
||||
const { fixtures } = campaign
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const municipality = await fixtures.claimMunicipality()
|
||||
const title = fixtures.value('Café mobile na agenda')
|
||||
|
||||
await campaign.login(page, coordinator.email!, coordinator.password)
|
||||
await page.goto(`${campaign.baseURL}/campanha/agenda?municipality=${municipality.id}`)
|
||||
|
||||
// FullCalendar renders the grid lazily and re-lays out on mount: wait for
|
||||
// the load to finish before interacting (same pattern as the desktop test).
|
||||
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()
|
||||
// Click the time slot itself — C104's all-day lane sits above the timed
|
||||
// grid, so the old day-cell nth() offset no longer lands on the slot.
|
||||
// `force` bypasses the timed-row content overlay; the FullCalendar grid
|
||||
// still receives the click and resolves the 14:00 date from the position.
|
||||
await slotLocator.scrollIntoViewIfNeeded()
|
||||
await slotLocator.click({ force: true })
|
||||
|
||||
// C103 — the sheet opens from the TOP, hugging the usable viewport edge.
|
||||
const sheet = page.getByRole('dialog', { name: /Nova atividade/ })
|
||||
await expect(sheet).toBeVisible()
|
||||
const sheetBox = await sheet.boundingBox()
|
||||
if (!sheetBox) throw new Error('O sheet não expôs bounding box.')
|
||||
expect(sheetBox.y).toBeLessThanOrEqual(1)
|
||||
|
||||
// Label-less form: the placeholder gives context, the sr-only label keeps
|
||||
// the field addressable and accessible.
|
||||
await page.getByLabel('Título *').fill(title)
|
||||
await expect(page.getByLabel('Município *')).toHaveValue(municipality.name)
|
||||
|
||||
// The footer is FIXED: "Salvar" is visible without scrolling on a small
|
||||
// viewport — the acceptance center of C103.
|
||||
await expect(page.getByRole('button', { name: 'Salvar' })).toBeVisible()
|
||||
|
||||
// Date/time opens as a nested bottom sheet (no popover) with "Pronto".
|
||||
await page.getByLabel('Início *').click()
|
||||
const picker = page.getByRole('dialog', { name: /Início —/ })
|
||||
await expect(picker).toBeVisible()
|
||||
await expect(picker.getByRole('combobox', { name: 'Hora' })).toBeVisible()
|
||||
await picker.getByRole('button', { name: 'Pronto' }).click()
|
||||
await expect(picker).toBeHidden()
|
||||
|
||||
await page.getByRole('button', { name: 'Salvar' }).click()
|
||||
await expect(page.getByText(title, { exact: true })).toBeVisible({ timeout: 15_000 })
|
||||
await expect(page.getByText('14:00 – 14:30', { exact: true })).toBeVisible()
|
||||
await expect(page).toHaveURL(new RegExp('/campanha/agenda.*'))
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -278,100 +278,39 @@ describe('ActivityInlineCreate — overlay de criação inline', () => {
|
|||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('renderiza o mesmo conteúdo no bottom sheet mobile', () => {
|
||||
it('renderiza o sheet do topo mobile com labels ocultos e rodapé fixo', () => {
|
||||
renderOverlay({ agendaState: { municipality: 12 }, isNarrow: true })
|
||||
|
||||
// C103 — labels stay in the a11y tree (sr-only), placeholders give context
|
||||
// and required fields keep the asterisk.
|
||||
expect(screen.getByLabelText('Título *')).toBeTruthy()
|
||||
expect(screen.getByLabelText('Início *').textContent).toBe('07/08/2026 às 13:00')
|
||||
expect((screen.getByLabelText('Título *') as HTMLInputElement).placeholder).toBe(
|
||||
'Adicionar título *',
|
||||
)
|
||||
expect(screen.getByLabelText('Início *').textContent).toMatch(/07\/08\/2026 às 13:00/)
|
||||
expect(screen.getByLabelText('Início *').textContent).toContain('*')
|
||||
expect((screen.getByLabelText('Município *') as HTMLInputElement).placeholder).toBe(
|
||||
'Município *',
|
||||
)
|
||||
expect((screen.getByLabelText('Local (opcional)') as HTMLInputElement).placeholder).toBe(
|
||||
'Local (opcional)',
|
||||
)
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Responsáveis: Adicionar responsáveis' }),
|
||||
).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: 'Salvar' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('alterna "Todo o dia" e esconde os seletores de horário (C104)', () => {
|
||||
renderOverlay({ agendaState: { municipality: 12 } })
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Todo o dia'))
|
||||
|
||||
expect(screen.getByLabelText('Início *').textContent).toBe('07/08/2026')
|
||||
expect(screen.getByLabelText('Término').textContent).toBe('07/08/2026')
|
||||
it('abre o seletor de data/hora como bottom sheet no mobile, com Pronto', () => {
|
||||
renderOverlay({ agendaState: { municipality: 12 }, isNarrow: true })
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Início *'))
|
||||
const dialogs = screen.getAllByRole('dialog')
|
||||
const picker = within(dialogs[dialogs.length - 1])
|
||||
expect(picker.queryByRole('combobox', { name: 'Hora' })).toBeNull()
|
||||
expect(picker.queryByRole('combobox', { name: 'Minuto' })).toBeNull()
|
||||
expect(picker.getByRole('combobox', { name: 'Hora' })).toBeTruthy()
|
||||
expect(picker.getByRole('button', { name: 'Pronto' })).toBeTruthy()
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Todo o dia'))
|
||||
expect(screen.getByLabelText('Início *').textContent).toBe('07/08/2026 às 13:00')
|
||||
})
|
||||
|
||||
it('salva dia inteiro com instantes de meia-noite baiana (C104)', async () => {
|
||||
const onCreated = vi.fn()
|
||||
mocks.createInline.mockResolvedValue({ ok: true })
|
||||
renderOverlay({ agendaState: { municipality: 12 }, onCreated })
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Título *'), {
|
||||
target: { value: 'Café com apoiadores' },
|
||||
})
|
||||
fireEvent.click(screen.getByLabelText('Todo o dia'))
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Início *'))
|
||||
const dialogs = screen.getAllByRole('dialog')
|
||||
const startPicker = within(dialogs[dialogs.length - 1])
|
||||
fireEvent.click(startPicker.getByRole('button', { name: /15 de agosto de 2026/ }))
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Término'))
|
||||
const endPicker = within(
|
||||
screen.getAllByRole('dialog')[screen.getAllByRole('dialog').length - 1],
|
||||
)
|
||||
fireEvent.click(endPicker.getByRole('button', { name: /17 de agosto de 2026/ }))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Salvar' }))
|
||||
|
||||
await waitFor(() => expect(mocks.createInline).toHaveBeenCalledTimes(1))
|
||||
expect(mocks.createInline).toHaveBeenCalledWith({
|
||||
title: 'Café com apoiadores',
|
||||
municipality: 12,
|
||||
allDay: true,
|
||||
startAt: '2026-08-15T03:00:00.000Z',
|
||||
endAt: '2026-08-17T03:00:00.000Z',
|
||||
})
|
||||
expect(onCreated).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('bloqueia dia inteiro com término anterior ao início (C104)', async () => {
|
||||
mocks.createInline.mockResolvedValue({ ok: true })
|
||||
renderOverlay({ agendaState: { municipality: 12 } })
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Título *'), {
|
||||
target: { value: 'Café com apoiadores' },
|
||||
})
|
||||
fireEvent.click(screen.getByLabelText('Todo o dia'))
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Início *'))
|
||||
const startPicker = within(screen.getAllByRole('dialog').at(-1) as HTMLElement)
|
||||
fireEvent.click(startPicker.getByRole('button', { name: /15 de agosto de 2026/ }))
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Término'))
|
||||
const endPicker = within(screen.getAllByRole('dialog').at(-1) as HTMLElement)
|
||||
fireEvent.click(endPicker.getByRole('button', { name: /10 de agosto de 2026/ }))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Salvar' }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByText('A data de término deve ser igual ou posterior à de início.'),
|
||||
).toBeTruthy(),
|
||||
)
|
||||
expect(mocks.createInline).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('passa "Todo o dia" e as datas para "Mais detalhes" (C104)', () => {
|
||||
renderOverlay({ agendaState: { municipality: 12 } })
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Todo o dia'))
|
||||
const link = screen.getByRole('link', { name: 'Mais detalhes' })
|
||||
expect(link.getAttribute('href')).toBe(
|
||||
'/campanha/atividades/nova?allDay=1&startAt=2026-08-07&endAt=2026-08-07&municipality=12&returnTo=%2Fcampanha%2Fagenda%3Fmunicipality%3D12',
|
||||
)
|
||||
fireEvent.click(picker.getByRole('button', { name: 'Pronto' }))
|
||||
expect(screen.queryByRole('button', { name: 'Pronto' })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue