mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
Merge pull request #473 from fsolla/C93-gerar-link-de-import-sem-filtros-agenda-do-escopo
feat(C93): allow generating calendar import link without filters
This commit is contained in:
commit
76915b9410
8 changed files with 325 additions and 39 deletions
|
|
@ -4,11 +4,14 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
|
|||
|
||||
---
|
||||
|
||||
**Recently resolved (2026-08-09):** **C93** (Issue #437) — botão "Link de import" da agenda destravado **sem filtros** (sucessor direto do C16): com zero filtros ativos, staff gera o link do feed **completo dentro do escopo de leitura do criador** — coordinator/candidate = tudo; advisor = portfólio dos municípios que administra — com fail-closed/revogação de C16 intactos. O servidor já cobria o caso (vazio = escopo do criador em `buildFeedWhere`/`resolveFeedCreatorAccess`; descrição do campo `filterMunicipality`); a entrega removeu o gate `hasFilters → disabled` de `CalendarFeedButton` (prop morto `state` incluído), manteve `hasFilters` só no "Limpar filtros" e neutralizou a copy do diálogo ("este recorte" → "a agenda"). Testes: 3 int de conteúdo sem filtro (coordinator = A+B; advisor = só o portfólio, nada fora vaza; advisor sem município → feed vazio fail-closed) + unit component (botão habilitado com zero filtros e fluxo nomear→criar). Sem migration, access, Consent ou schema. Intenção [`c93-gerar-link-import-sem-filtros.md`](plans/c93-gerar-link-import-sem-filtros.md); impl [`c93-gerar-link-import-sem-filtros-impl.md`](plans/c93-gerar-link-import-sem-filtros-impl.md).
|
||||
|
||||
**Recently resolved (2026-08-09):** **B176** (Issue #458) — `/campanha/municipios` ganhou três dimensões de filtro na omnibox — **Dobradinha** (nome do contato), **Liderança** (nome do contato) e **Partido** (sigla) — com recortes "Sem dobradinha"/"Sem liderança"/"Sem partido" (precedente "Sem nível"). Params aditivos `stateDeputy`/`leadership`/`party` na URL congelada (sentinelas `sem_dobradinha`/`sem_lideranca`, reuso de `sem_partido`); `buildMunicipalityListWhere` ganhou um catálogo request-scoped fail-closed (`municipalityRelationSets.ts`, lazy: metade liderança só com filtro ativo) que vira as leituras reversa (`leadership.municipalities`) e cruzada (`stateDeputy.party`) em `where` — sem migration, paginação nativa mantida; facets estendidas (`stateDeputyIDs`/`leadershipOptions`/`parties`) e mapa compartilha o catálogo. **Coluna Dobradinhas liberada ao Assessor** (staff-wide): servidor já estava scoped (B37/B157 `reloadStaffActor` + `canUpdateMunicipality`), a mudança foi só de gate de UI (lista/mobile/picker) + catálogo full-staff para a célula (decisão do gate humano 2026-08-09). Extrato do par "sentinela + `exists:false`" em `campaignListUrl` (`NO_PARTY_FILTER_VALUE`, `splitAbsenceFilterValues`, `collapseListWhereOrBranches`) com 5 call sites. E2E: assessor vê/edita a coluna no portfólio; filtros pelas três dimensões; teste de coluna-restrita invertido. Intenção [`filtros-municipios-dobradinha-lideranca.md`](plans/filtros-municipios-dobradinha-lideranca.md); impl [`filtros-municipios-dobradinha-lideranca-impl.md`](plans/filtros-municipios-dobradinha-lideranca-impl.md).
|
||||
|
||||
**Recently resolved (2026-08-09):** **B174 reverso** (sem Issue — pedido direto) — a Sollinha (`/campanha/api/ai-chat`) voltou do `@ai-sdk/deepinfra` para o **`@ai-sdk/deepseek`** (API oficial `api.deepseek.com`): o cache-hit do V4-Flash direto custa **$0.0028/M** contra **$0.018/M** na Deep Infra (~6,4× mais barato), e a Sollinha tem prefixo estável (system prompt + tools + histórico) — o hit ratio alto derruba o custo efetivo mesmo com miss ($0.14 vs $0.09) e output ($0.28 vs $0.18) mais caros. Mesmo modelo (`deepseek-v4-flash`), mesmas tools; `@ai-sdk/deepseek` **pinado em `3.0.19`** (não `^3.0.19`, que flutuaria a 3.0.26 e puxaria a ferroviária `provider@4.0.7` duplicada — o lock ficou na `4.0.4/5.0.18` do `ai@7.0.47`, verificado no lockfile). **STT fica na Deep Infra** (B173): `whisper-large-v3` a $0.00045/min de áudio é o mais barato verificado (Groq $0.0019, OpenAI $0.006); `.env.example` agora documenta **as duas chaves** — `DEEPSEEK_API_KEY` (LLM) e `DEEPINFRA_API_KEY` (STT). Aviso da DeepSeek: aumento de preço anunciado ("em breve"). Sem migration, access, UI ou teste novo; smoke de deploy = 2–3 perguntas forçando tool calling real antes de confiar.
|
||||
|
||||
**Recently resolved (2026-08-09):** **OPS20** (Issue #464) — o e2e de cada worktree agora usa a porta do slot próprio (`3100+slot`), nunca o fallback `3000`: o `.env.test.local` (que o `playwright.config.ts` lê em `playwright.config.ts:14`) passou a carregar `PLAYWRIGHT_BASE_URL` — antes ele só ia para o `.env.local` (que o config **não** lê), então dois `pnpm test:e2e` em worktrees paralelos subiam dois dev servers na porta `3000`. Causa-raiz tratada além do sintoma: os dois blocos env inline do `provision` (que já tinham derivado uma vez) viraram um builder puro único `worktreeEnvFileContents` em `scripts/lib/worktree-env.mjs` que deriva `NEXT_PUBLIC_SITE_URL` + `PLAYWRIGHT_BASE_URL` do mesmo `env.devPort` para **ambos** `.env.local`/`.env.test.local` (paridade estrutural, drift impossível) + unit tests de paridade em `tests/unit/worktree.unit.spec.ts` (falham se o drift voltar). `playwright.config.ts` intocado (a variável certa já existia; só faltava chegar). Sem Docker (fallback) continua degradando ao compartilhado — fora de escopo. Sem migration. Intenção [`porta-e2e-isolada-por-worktree.md`](plans/porta-e2e-isolada-por-worktree.md); impl [`porta-e2e-isolada-por-worktree-impl.md`](plans/porta-e2e-isolada-por-worktree-impl.md).
|
||||
|
||||
**Recently resolved (2026-08-08):** **B174** (Issue #449) — a Sollinha (`/campanha/api/ai-chat`) trocou o provider de `@ai-sdk/deepseek` (API oficial `api.deepseek.com`) pelo **`@ai-sdk/deepinfra`** no caminho OpenAI-compatível da Deep Infra — **mesmo modelo** (`deepseek-ai/DeepSeek-V4-Flash`), **mesmas tools** e **mesma chave** que os agentes do time usam (um provedor, uma chave, uma fatura). Swap de 2 linhas no `route.ts`; dep `@ai-sdk/deepinfra@3.0.20` pinada na mesma ferroviária do `ai@7.0.47` (provider 4.0.4 / provider-utils 5.0.18, `pnpm why` confirma cópia única — nada de 4.0.7 duplicado), `@ai-sdk/deepseek` removido (dead dep, knip); `.env.example` `DEEPSEEK_API_KEY` → `DEEPINFRA_API_KEY` (B173 STT/Whisper reusa a mesma chave). Sem migration, access, UI ou teste novo — e2e já mocka o SSE (sem chave real no CI); rollback documentado = `git revert` (restaura provider/baseURL antiga); smoke de deploy = 2–3 perguntas forçando tool calling real antes de confiar o flip. Intenção [`sollinha-no-deepinfra.md`](plans/sollinha-no-deepinfra.md); impl [`sollinha-no-deepinfra-impl.md`](plans/sollinha-no-deepinfra-impl.md).
|
||||
|
||||
**Recently resolved (2026-08-08):** **B170** (Issue #441, follow-up B169) — o **contrato do colapso de chips virou spec unitário** (`tests/unit/relationChipCellCollapse.unit.spec.tsx`): `RelationChipCell` renderiza 16 chips com `getBoundingClientRect` stubado (4/linha, 20px, gap 6 — a matemática real de `COLLAPSED_CHIP_ROWS`/`CHIP_GAP_PX`) e o spec pina que o slice fica em 12 (3 linhas), "Ver mais…" expande para 16 e "Ver menos" recolhe para 12; casos tudo-cabe (8) e exatamente-cheio (12) não mostram toggle. Antes, os specs das células usavam `measureOverflow: false` ou colapsavam para 1 chip — a mudança de timing do B169 (medição em `useLayoutEffect`) não tinha regressão do comportamento de colapso. **F2 medido e deferido em vez de eliminado**: `PerformanceObserver('layout-shift')` no worktree confirma o resíduo pré-hidratação exatamente como registrado no B169 — load SSR→hidratação com shifts únicos `0.139/0.043/0.257` (fontes: shell — sidebar/Drawer/main/col-resize — nenhuma célula de chips) e a linha da célula indo `97px/42chips → 57px/0chips` numa única transição; **ordenação client-side (RSC) → zero shifts novos**. Eliminar a paint pré-hidratação exigiria estimativa de largura por label (rejeitada pelo dono, que mede layout real de propósito) ou mudança de layout/regra de colapso (anti-goal) — o shift único do primeiro load é aceitável num painel interno staff. Sem migration, dados, access ou mudança visual. Intenção [`estabilizar-lista-dobradinhas-followup.md`](docs/plans/estabilizar-lista-dobradinhas-followup.md); impl [`estabilizar-lista-dobradinhas-followup-impl.md`](docs/plans/estabilizar-lista-dobradinhas-followup-impl.md).
|
||||
|
|
|
|||
86
docs/plans/c93-gerar-link-import-sem-filtros-impl.md
Normal file
86
docs/plans/c93-gerar-link-import-sem-filtros-impl.md
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
# Impl: C93 — Gerar link de import sem filtros (agenda completa do escopo)
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-08-09
|
||||
Issue: #437
|
||||
Intenção: docs/plans/c93-gerar-link-import-sem-filtros.md
|
||||
Appetite restante: ~0,5 dia eng (herdado; entrega menor que o appetite)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** com **nenhum filtro ativo**, o botão "Link de import" permite gerar o link; o feed sem filtro cobre a agenda completa **dentro do escopo de leitura do criador** (coordinator/candidate = tudo; advisor = municípios administrados); revogar/listar e o endpoint de leitura seguem fail-closed (invariante de C16).
|
||||
- **O que NÃO negociar:** fail-closed no read do feed (segredo não vaza / criador desativado → para de servir); escopo do criador preservado no feed "sem filtro" (não é backdoor); líder lockdown; sem PII/Consent novo; revogação intacta.
|
||||
- **O que reavaliar:** a hipótese da intenção ("construir feed sem filtros = só o escopo do criador" em `calendarFeed.ts`/action). **Explorando o código, o servidor já cobre o caso sem filtro por inteiro** — o único bloqueio real é o gate de UI `hasFilters → disabled`. Isso muda o formato da entrega: unlock de UI + testes que provam o aceite de conteúdo (que hoje não existem), sem tocar schema/server.
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[ActivityAgendaFilters] -- para de passar hasFilters/state --> B[CalendarFeedButton]
|
||||
B -- sempre habilitado, copy neutra --> C[diálogo nomear→copiar→revogar]
|
||||
C --> D[createCalendarFeedLink com filtros todos vazios]
|
||||
D --> E[servidor: filterMunicipality vazio = escopo do criador — já pronto]
|
||||
F[int test: feed conteúdo sem filtro] --> G[coordinator=tudo / advisor=portfolio]
|
||||
```
|
||||
|
||||
**Opções consideradas:**
|
||||
|
||||
- **A. Unlock de UI + testes de conteúdo (recomendada):** remover o gate `hasFilters` e o prop morto `state` de `CalendarFeedButton`; manter `hasFilters` apenas para o botão "Limpar filtros"; ajustar copy neutra no diálogo; adicionar int tests (feed sem filtro respeita o escopo do criador) e um unit component test (botão habilitado sem filtros).
|
||||
- **B. Médio:** como A, mas sem remover o prop `state` de `CalendarFeedButton` (mantendo `_state` morto "para o C94"). Deixa código morto num arquivo que já estamos editando.
|
||||
- **C. Servidor explícito ("all" marker):** adicionar uma flag/breakpoint no schema/action tipo `scope: 'all'` para "sem filtro". Internet do work: o vazio **já significa** "tudo no escopo" (descrição do campo `filterMunicipality` na collection + `buildFeedWhere`/`resolveFeedCreatorAccess`); inventar uma representação paralela é adicionar superfície sem ganho.
|
||||
|
||||
**Recomendação: A** — porque mantém o outcome da intenção com a menor superfície: o servidor já está pronto (decisão de C16), o bloqueio é puramente o `disabled` do botão; remove código morto (`state: _state`, `hasFilters` feed-only) no caminho; e os testes novos fecham a lacuna de verificação do aceite de conteúdo — hoje nenhum teste prova que um feed **sem filtro** serve a agenda completa do escopo (os int atuais só cobrem create/revoke/access, e o unit só o `generateICalFeed`).
|
||||
**Rejeitadas:** B porque deixa `state: _state` morto num arquivo que editamos (C94 reconstroi a localização do controle de qualquer forma — o prop não é necessário como ponte); C porque inventa representação paralela ao contrato existente de C16 ("vazio = escopo do criador") e infla o appetite sem demand de produto.
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
> **Reconciliação com o C94 (merge em main durante a execução):** o C94 moveu o
|
||||
> "Link de import" do card de filtros para o header do app (ícone) + FAB mobile,
|
||||
> e o gate `hasFilters → disabled` passou a viver como `canGenerate`
|
||||
> (`page.tsx` → `AgendaFeedChrome` → `CalendarFeedDialog`), com o C94 documentando
|
||||
> explicitamente "C93 cobre gerar sem filtro". A execução final removeu o gate
|
||||
> **no estado final do C94**: o `CalendarFeedButton` antigo foi apagado (deleção
|
||||
> do C94 mantida; as mudanças C93 nele tornaram-se obsoletas) e o gate virou
|
||||
> remoção estrutural do `canGenerate` — sem knob morto com um único estado.
|
||||
|
||||
- **`agenda/page.tsx`**: removido `canGenerateFeed` e o prop `canGenerate` do `AgendaFeedChrome` — staff sempre pode gerar (zero filtros = escopo do criador no servidor).
|
||||
- **`AgendaFeedChrome`** (`src/components/campaign/activity/AgendaFeedChrome.tsx`): removido o prop `canGenerate`; ícone do header sem `disabled`/tooltip condicional.
|
||||
- **`CalendarFeedDialog`** (`src/components/campaign/activity/CalendarFeedDialog.tsx`): removido o prop `canGenerate` (guard do create, aviso "Aplique filtros…" e `disabled` do "Gerar link"); copy neutra ("este recorte" → "a agenda").
|
||||
- **`activityQuickActions.ts`**: copy do FAB "Link de import" neutra (recorte → agenda).
|
||||
- **Migration:** sem migration (UI/copy apenas; schema inalterado).
|
||||
- **Access / Consent:** sem mudança — servidor e access de C16/C96 intactos; sem PII/Consent novo.
|
||||
- **UI:** Impeccable A — encaixe nos controles do C94 (ícone header + FAB + diálogo) sem rearranjo.
|
||||
|
||||
### Testes
|
||||
|
||||
- **Int** (`tests/int/calendarFeed.int.spec.ts`, adicionar 2 testes, padrão `validActivityInput`/`stub<ActivityCreateData>` de `campaignActivity.int.spec.ts`):
|
||||
1. _Coordinator cria feed sem filtros e o feed cobre todas as municipalidades do escopo:_ atividades `confirmado` amanhã (janela 90/365 dias) nas municipalidades A e B → feed só com `label` → `resolveFeedCreatorAccess` = `{ accessible: true, municipalityIds: null }` → `loadFeedActivities` retorna atividades de A **e** B.
|
||||
2. _Advisor cria feed sem filtros e o feed cobre só as municipalidades que administra:_ advisor administra A; atividades em A e B → `resolveFeedCreatorAccess` = `{ accessible: true, municipalityIds: [A] }` → `loadFeedActivities` retorna só a de A; a de B **nunca** vaza.
|
||||
- **Unit component** (`tests/unit/calendarFeedDialog.unit.spec.tsx`, novo, Testing Library): o diálogo do C94 abre o formulário de criação **sem gate de filtros** (sem aviso "Aplique filtros…") e cria o feed com apenas o label via `onCreateFeed` — aceite C93 no estado final do C94.
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **UI unlock (estado final do C94)** — `page.tsx` + `AgendaFeedChrome` + `CalendarFeedDialog` + `activityQuickActions` (remoção do gate `canGenerate` + copy neutra).
|
||||
2. **Testes** — int de conteúdo de escopo (coordinator/advisor, sem filtro) + unit component do diálogo sem gate.
|
||||
3. **Gates** — `pnpm gate:fast` (lint/tsc/unit), `pnpm test` (unit+int), `pnpm build` local.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- **C94 (botão vira ícone no header/FAB)** — **já mergeado em main durante a execução**; a entrega reconciliou com o estado final (remoção estrutural do `canGenerate`), sem rearranjo de layout.
|
||||
- **Representação explícita "all" no schema/action** — corte: vazio já é o contrato de C16; sem superfície nova.
|
||||
- **Prompt de confirmação "tudo vs recorte"** — produto decidiu: feed sem filtro é direto.
|
||||
- **Refactor de `hasFilters` geral / copy do placeholder do label** — fora do escopo (sem regressão visual; placeholder segue válido).
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Regressão em consumers do `CalendarFeedButton`:** único call site é `ActivityAgendaFilters` (grep); TS pega qualquer outro.
|
||||
- **Int test depender de janela deslizante (90/365 dias):** usar `startAt` amanhã (padrão `validActivityInput`) — sempre dentro da janela.
|
||||
- **Contaminação entre testes (atividades/municípios):** fixtures alocam município único via sequência e limpam `activity`/`touchedMunicipalities`; atividades criadas via `payload.create` + `fixtures().own('activity', …)`.
|
||||
- **`filterDeputyPresent: false` gravado quando sem filtro:** semanticamente vazio para o leitor (`if (feed.filterDeputyPresent)`), sem efeito no conteúdo — já coberto pelo int test de conteúdo.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto da intenção ainda coberto (botão habilitado sem filtros; feed = escopo do criador; fail-closed/revogação intactos)
|
||||
- [ ] Invariantes AGENTS/engineering-standards (identificadores EN, copy pt-BR, sem toque em schema/access de C16)
|
||||
- [ ] Testes: int de conteúdo de escopo sem filtro (coordinator=tudo, advisor=portfolio) + unit do botão habilitado
|
||||
- [ ] `pnpm gate:fast` + `pnpm test` + `pnpm build` verdes
|
||||
|
|
@ -53,8 +53,6 @@ export default async function AgendaPage({ searchParams }: AgendaPageProps) {
|
|||
const accessibleHref = buildActivityAgendaHref(state)
|
||||
if (accessibleHref !== resolvedUrl.href) redirect(accessibleHref)
|
||||
|
||||
const canGenerateFeed = Boolean(state.municipality || state.deputyPresent || state.tag)
|
||||
|
||||
return (
|
||||
<CampaignPageShell className="gap-6">
|
||||
<AgendaFeedChrome
|
||||
|
|
@ -63,7 +61,6 @@ export default async function AgendaPage({ searchParams }: AgendaPageProps) {
|
|||
label: f.label,
|
||||
createdAt: f.createdAt,
|
||||
}))}
|
||||
canGenerate={canGenerateFeed}
|
||||
onCreateFeed={async (label) => {
|
||||
'use server'
|
||||
return createCalendarFeedLink({
|
||||
|
|
|
|||
|
|
@ -14,23 +14,17 @@ import { Button } from '@/components/ui/button'
|
|||
|
||||
type AgendaFeedChromeProps = {
|
||||
feeds: CalendarFeedSummary[]
|
||||
/** Whether the current agenda recorte has filters (feed gate, C93 handles "sem filtros"). */
|
||||
canGenerate: boolean
|
||||
onCreateFeed: (label: string) => Promise<CreateCalendarFeedResult>
|
||||
onRevokeFeed: (feedId: number) => Promise<{ ok: boolean }>
|
||||
}
|
||||
|
||||
/**
|
||||
* Agenda page → app chrome bridge for the calendar-feed surface (C94):
|
||||
* registers the desktop header icon (agenda-contextual), exposes the sheet to
|
||||
* the mobile FAB quick action and owns the single shared dialog instance.
|
||||
* Agenda page → app chrome bridge for the calendar-feed surface (C94, gate
|
||||
* unlocked by C93): registers the desktop header icon (agenda-contextual),
|
||||
* exposes the sheet to the mobile FAB quick action and owns the single shared
|
||||
* dialog instance.
|
||||
*/
|
||||
export const AgendaFeedChrome = ({
|
||||
feeds,
|
||||
canGenerate,
|
||||
onCreateFeed,
|
||||
onRevokeFeed,
|
||||
}: AgendaFeedChromeProps) => {
|
||||
export const AgendaFeedChrome = ({ feeds, onCreateFeed, onRevokeFeed }: AgendaFeedChromeProps) => {
|
||||
const { setContext } = useCampaignQuickActionContext()
|
||||
const [open, setOpen] = useState(false)
|
||||
const openFeed = useCallback(() => setOpen(true), [])
|
||||
|
|
@ -50,14 +44,13 @@ export const AgendaFeedChrome = ({
|
|||
size="icon"
|
||||
className="size-11 hidden shrink-0 md:inline-flex"
|
||||
aria-label="Link de import"
|
||||
title={canGenerate ? 'Link de import' : 'Aplique filtros para gerar um link de import'}
|
||||
disabled={!canGenerate}
|
||||
title="Link de import"
|
||||
onClick={openFeed}
|
||||
>
|
||||
<CalendarIcon className="size-5" aria-hidden />
|
||||
</Button>
|
||||
),
|
||||
[canGenerate, openFeed],
|
||||
[openFeed],
|
||||
)
|
||||
|
||||
return (
|
||||
|
|
@ -68,7 +61,6 @@ export const AgendaFeedChrome = ({
|
|||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
feeds={feeds}
|
||||
canGenerate={canGenerate}
|
||||
onCreateFeed={onCreateFeed}
|
||||
onRevokeFeed={onRevokeFeed}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -38,8 +38,6 @@ type CalendarFeedDialogProps = {
|
|||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
feeds: CalendarFeedSummary[]
|
||||
/** Whether the current agenda recorte can generate a feed (has filters). */
|
||||
canGenerate: boolean
|
||||
onCreateFeed: (label: string) => Promise<CreateCalendarFeedResult>
|
||||
onRevokeFeed: (feedId: number) => Promise<{ ok: boolean }>
|
||||
}
|
||||
|
|
@ -47,13 +45,14 @@ type CalendarFeedDialogProps = {
|
|||
/**
|
||||
* Calendar feed dialog (nomear → copiar → revogar). Shared by the desktop
|
||||
* header icon and the mobile FAB quick action — rendered once per host, opened
|
||||
* by either trigger. Bottom sheet on mobile, dialog on desktop (C94).
|
||||
* by either trigger. Bottom sheet on mobile, dialog on desktop (C94). Always
|
||||
* enabled for staff (C93): with zero filters the feed covers the creator's
|
||||
* full read scope.
|
||||
*/
|
||||
export const CalendarFeedDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
feeds,
|
||||
canGenerate,
|
||||
onCreateFeed,
|
||||
onRevokeFeed,
|
||||
}: CalendarFeedDialogProps) => {
|
||||
|
|
@ -65,7 +64,7 @@ export const CalendarFeedDialog = ({
|
|||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!label.trim() || !canGenerate || isCreating) return
|
||||
if (!label.trim() || isCreating) return
|
||||
setIsCreating(true)
|
||||
setError(null)
|
||||
const result = await onCreateFeed(label.trim())
|
||||
|
|
@ -154,12 +153,6 @@ export const CalendarFeedDialog = ({
|
|||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{!canGenerate ? (
|
||||
<p className="rounded-lg bg-muted p-3 text-sm text-muted-foreground">
|
||||
Aplique filtros à agenda para gerar um link de import.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="feed-label" className="text-sm font-medium">
|
||||
Nome do feed
|
||||
|
|
@ -211,11 +204,7 @@ export const CalendarFeedDialog = ({
|
|||
Cancelar
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleCreate}
|
||||
disabled={!label.trim() || !canGenerate || isCreating}
|
||||
>
|
||||
<Button type="button" onClick={handleCreate} disabled={!label.trim() || isCreating}>
|
||||
{isCreating ? 'Gerando...' : 'Gerar link'}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -231,8 +220,8 @@ export const CalendarFeedDialog = ({
|
|||
<DrawerHeader className="text-left">
|
||||
<DrawerTitle>Sincronizar com Google Calendar</DrawerTitle>
|
||||
<DrawerDescription>
|
||||
Gere um link de import para sincronizar este recorte da agenda com o Google Calendar.
|
||||
Alterações em Teqo serão refletidas automaticamente.
|
||||
Gere um link de import para sincronizar a agenda com o Google Calendar. Alterações em
|
||||
Teqo serão refletidas automaticamente.
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<div className="overflow-y-auto px-4 pb-2">{body}</div>
|
||||
|
|
@ -250,8 +239,8 @@ export const CalendarFeedDialog = ({
|
|||
<DialogHeader>
|
||||
<DialogTitle>Sincronizar com Google Calendar</DialogTitle>
|
||||
<DialogDescription>
|
||||
Gere um link de import para sincronizar este recorte da agenda com o Google Calendar.
|
||||
Alterações em Teqo serão refletidas automaticamente.
|
||||
Gere um link de import para sincronizar a agenda com o Google Calendar. Alterações em
|
||||
Teqo serão refletidas automaticamente.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{body}
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ const listQuickActions = (context: CampaignQuickActionContext): readonly Campaig
|
|||
id: 'import-calendar',
|
||||
label: 'Link de import',
|
||||
icon: Calendar,
|
||||
description: 'Gerar link para sincronizar este recorte da agenda com o Google Calendar',
|
||||
description: 'Gerar link para sincronizar a agenda com o Google Calendar',
|
||||
onAction: context.openCalendarFeed,
|
||||
},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -357,4 +357,135 @@ describe('campaign calendar feed domain', () => {
|
|||
const activities = await loadFeedActivities(payload, feed, access.municipalityIds)
|
||||
expect(activities.map((doc) => doc.id)).toContain(activity.id)
|
||||
})
|
||||
|
||||
it('covers every municipality of the coordinator scope in a filterless feed (C93)', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const municipalityA = await fixtures.getMunicipality()
|
||||
const municipalityB = await fixtures.getMunicipality()
|
||||
|
||||
const activityInA = await createActivityRecord(
|
||||
payload,
|
||||
coordinator,
|
||||
validActivityInput(municipalityA.id, fixtures.value('Atividade A')),
|
||||
)
|
||||
fixtures.own('activity', activityInA.id)
|
||||
const activityInB = await createActivityRecord(
|
||||
payload,
|
||||
coordinator,
|
||||
validActivityInput(municipalityB.id, fixtures.value('Atividade B')),
|
||||
)
|
||||
fixtures.own('activity', activityInB.id)
|
||||
|
||||
const ok = requireOk(
|
||||
await createCalendarFeedLinkRecord(payload, coordinator, { label: 'Agenda completa' }),
|
||||
)
|
||||
fixtures.own('calendarFeed', ok.feedId)
|
||||
|
||||
const feed = await payload.findByID({
|
||||
collection: 'calendarFeed',
|
||||
id: ok.feedId,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
expect(feed.filterMunicipality).toBeFalsy()
|
||||
|
||||
const access = await resolveFeedCreatorAccess(payload, feed)
|
||||
expect(access.accessible).toBe(true)
|
||||
expect(access.municipalityIds).toBeNull()
|
||||
|
||||
const activities = await loadFeedActivities(payload, feed, access.municipalityIds)
|
||||
const ids = activities.map((activity) => activity.id)
|
||||
expect(ids).toContain(activityInA.id)
|
||||
expect(ids).toContain(activityInB.id)
|
||||
})
|
||||
|
||||
it('scopes a filterless feed to every municipality the advisor administers (C93)', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const advisor = await fixtures.createCampaignUser('advisor')
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const municipalityA = await fixtures.getMunicipality()
|
||||
const municipalityB = await fixtures.getMunicipality()
|
||||
const municipalityC = await fixtures.getMunicipality()
|
||||
await fixtures.assignMunicipalityAdvisors(municipalityA, [advisor.id])
|
||||
await fixtures.assignMunicipalityAdvisors(municipalityB, [advisor.id])
|
||||
|
||||
const activityInScopeA = await createActivityRecord(
|
||||
payload,
|
||||
coordinator,
|
||||
validActivityInput(municipalityA.id, fixtures.value('Atividade administrada A')),
|
||||
)
|
||||
fixtures.own('activity', activityInScopeA.id)
|
||||
const activityInScopeB = await createActivityRecord(
|
||||
payload,
|
||||
coordinator,
|
||||
validActivityInput(municipalityB.id, fixtures.value('Atividade administrada B')),
|
||||
)
|
||||
fixtures.own('activity', activityInScopeB.id)
|
||||
const activityOutOfScope = await createActivityRecord(
|
||||
payload,
|
||||
coordinator,
|
||||
validActivityInput(municipalityC.id, fixtures.value('Atividade fora do escopo')),
|
||||
)
|
||||
fixtures.own('activity', activityOutOfScope.id)
|
||||
|
||||
const ok = requireOk(
|
||||
await createCalendarFeedLinkRecord(payload, advisor, { label: 'Meu portfólio' }),
|
||||
)
|
||||
fixtures.own('calendarFeed', ok.feedId)
|
||||
|
||||
const feed = await payload.findByID({
|
||||
collection: 'calendarFeed',
|
||||
id: ok.feedId,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
||||
const access = await resolveFeedCreatorAccess(payload, feed)
|
||||
expect(access.accessible).toBe(true)
|
||||
const advisorMunicipalities = access.municipalityIds as number[]
|
||||
expect(advisorMunicipalities).toHaveLength(2)
|
||||
expect(advisorMunicipalities).toEqual(
|
||||
expect.arrayContaining([municipalityA.id, municipalityB.id]),
|
||||
)
|
||||
|
||||
const activities = await loadFeedActivities(payload, feed, advisorMunicipalities)
|
||||
const ids = activities.map((activity) => activity.id)
|
||||
expect(ids).toContain(activityInScopeA.id)
|
||||
expect(ids).toContain(activityInScopeB.id)
|
||||
expect(ids).not.toContain(activityOutOfScope.id)
|
||||
})
|
||||
|
||||
it('serves an empty filterless feed when the advisor administers no municipality (C93)', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const advisor = await fixtures.createCampaignUser('advisor')
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const unadministered = await fixtures.getMunicipality()
|
||||
|
||||
const activity = await createActivityRecord(
|
||||
payload,
|
||||
coordinator,
|
||||
validActivityInput(unadministered.id, fixtures.value('Nunca servida')),
|
||||
)
|
||||
fixtures.own('activity', activity.id)
|
||||
|
||||
const ok = requireOk(
|
||||
await createCalendarFeedLinkRecord(payload, advisor, { label: 'Sem municípios' }),
|
||||
)
|
||||
fixtures.own('calendarFeed', ok.feedId)
|
||||
|
||||
const feed = await payload.findByID({
|
||||
collection: 'calendarFeed',
|
||||
id: ok.feedId,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
||||
const access = await resolveFeedCreatorAccess(payload, feed)
|
||||
expect(access.accessible).toBe(true)
|
||||
expect(access.municipalityIds).toEqual([])
|
||||
|
||||
const activities = await loadFeedActivities(payload, feed, access.municipalityIds)
|
||||
expect(activities).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
88
tests/unit/calendarFeedDialog.unit.spec.tsx
Normal file
88
tests/unit/calendarFeedDialog.unit.spec.tsx
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { CalendarFeedDialog } from '@/components/campaign/activity/CalendarFeedDialog'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
|
||||
type DialogProps = Parameters<typeof CalendarFeedDialog>[0]
|
||||
|
||||
const renderDialog = (overrides: Partial<DialogProps> = {}) => {
|
||||
const onCreateFeed = vi.fn()
|
||||
const onRevokeFeed = vi.fn()
|
||||
render(
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<CalendarFeedDialog
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
feeds={[]}
|
||||
onCreateFeed={onCreateFeed}
|
||||
onRevokeFeed={onRevokeFeed}
|
||||
{...overrides}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
)
|
||||
return { onCreateFeed, onRevokeFeed }
|
||||
}
|
||||
|
||||
const matchMediaMock = vi.fn()
|
||||
|
||||
beforeAll(() => {
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
},
|
||||
)
|
||||
Element.prototype.scrollIntoView = () => {}
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
matchMediaMock.mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}))
|
||||
vi.stubGlobal('matchMedia', matchMediaMock)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllGlobals()
|
||||
Reflect.deleteProperty(Element.prototype, 'scrollIntoView')
|
||||
})
|
||||
|
||||
describe('CalendarFeedDialog (C93 — no filters needed)', () => {
|
||||
it('opens the naming form without any filter gate', () => {
|
||||
renderDialog()
|
||||
|
||||
const dialog = screen.getByRole('dialog')
|
||||
expect(within(dialog).getByText('Sincronizar com Google Calendar')).toBeTruthy()
|
||||
expect(within(dialog).getByLabelText('Nome do feed')).toBeTruthy()
|
||||
expect(within(dialog).queryByText(/Aplique filtros/)).toBeNull()
|
||||
})
|
||||
|
||||
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 })
|
||||
|
||||
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' }))
|
||||
|
||||
await waitFor(() => expect(onCreateFeed).toHaveBeenCalledWith('Agenda completa'))
|
||||
await within(dialog).findByDisplayValue(FEED_URL)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue