diff --git a/docs/plans/c94-agenda-combobox-header-acoes-rapidas-impl.md b/docs/plans/c94-agenda-combobox-header-acoes-rapidas-impl.md new file mode 100644 index 00000000..0e7a6fbe --- /dev/null +++ b/docs/plans/c94-agenda-combobox-header-acoes-rapidas-impl.md @@ -0,0 +1,93 @@ +# Impl: C94 — Agenda: filtro combobox único, header compacto (ícones) e ações no FAB mobile + +Status: rascunho +Atualizado em: 2026-08-09 +Issue: #438 +Intenção: docs/plans/c94-agenda-combobox-header-acoes-rapidas.md +Appetite restante: ~1–1,5 dia eng (herdado); sem ajuste + +## Leitura da intenção + +- **Outcome:** toolbar da agenda consolidada em um combobox único (chips removíveis + sugestões agrupadas), entrada de criação por viewport com um caminho só: desktop = `[combobox][carrinho giro][+ nova]` + "Link de import" como ícone agenda-contextual no header do app; mobile = sem botões de criação no corpo, tudo no drawer do FAB ("Nova atividade", "Link de import", "Planejar giro"). +- **O que NÃO negociar:** filtros hoje = Município (único) + Tag + Deputado presente (chip bool), sem busca textual nem multi-seleção; estado do filtro segue na URL (reload/share consistentes); "Link de import" desktop mora no **header do app**, não na toolbar; mobile abre o mesmo diálogo como **sheet**; sem regressão de criação inline (C91), giros, revogação de feeds, leader lockdown. +- **O que reavaliar (hipóteses da intenção):** + - A intenção sugere "ActivityAgendaFilters.tsx vira o adaptador do combobox" — confirmado, mas ele vira a **toolbar inteira** (combobox + ícones trailing), não apenas o adaptador. + - A intenção sugere "slot no header vs renderizar no shell pela rota — executor decide". Decidir: **slot via contexto de chrome** (reusável por C95), não render por rota no shell. + - A intenção não decide o que fazer com **"Link de import" no FAB quando não há filtros** (hoje o botão fica desabilitado). Decidir: abrir o sheet e explicar dentro (em vez de tile morta). + +## Abordagem recomendada + +```mermaid +flowchart LR + subgraph AgendaPage[AGENDA PAGE · server] + S[searchParams] --> P[CalendarFeedDialogProvider
feeds · createFeed(state) · revokeFeed · canGenerate] + end + P -->|registra| H[SetCampaignHeaderAction id=calendar-feed] + H -->|lê| DH[CampaignDesktopHeader · agenda-contextual] + P -->|setContext openCalendarFeed| QK[CampaignQuickActionContext] + QK --> resolveActivityQuickActions['list' + import-calendar onAction] + QK --> FAB[FAB drawer · mobile] + AF[ActivityAgendaFilters → tool bar omnibox] -->|trailing desktop| ICON[ícone carrinho giro + ícone + nova] + AF --> URN[useCampaignListFilterNavigation → buildActivityAgendaHref] +``` + +**Opções consideradas (combobox):** A) omnibox chassis existente (`CampaignListOmnibox` B127) | B) `StrictCombobox`/combobox mobile avulso | C) manter selects nativos. +**Recomendação:** A — reusa o chassis já estabelecido nas listas; o adaptador segue o padrão `activityOmnibox.ts` (chips/sugestões puros + tests B128). +**Rejeitadas:** B (chassis não tem chips/teclado do omnibox — duplicaria); C (é exatamente a duplicação que a Issue quer eliminar). + +**Opções consideradas (header "Link de import"):** A) slot por contexto de chrome (extender `CampaignPageChromeContext` com registro de ações de header) | B) renderizar no shell pela rota (`usePathname` no header + self-fetch de feeds/state via `useSearchParams`) | C) link simples para o dialog roteado. +**Recomendação:** A — "edit the owner": o contexto de chrome JÁ é o mecanismo por-página do shell; vira registro chaveado de `ReactNode` (reusável por C95 que precisa do mesmo header/slot). Mantém o conhecimento de domínio (feeds/state/action) na vertical de atividade. +**Rejeitadas:** B (acopla o shell compartilhado à URL/schema da agenda e exige `useSearchParams` no layout → Suspense; é o "renderizar na rota" que a intenção flagrou como risco); C (o diálogo não é rota; precisa de state). + +**Opções consideradas (FAB "Link de import" mobile):** A) estender `CampaignQuickAction` com `onAction` + `openCalendarFeed` no contexto de quick actions | B) ação href para uma rota de wizzard | C) overlay próprio da agenda. +**Recomendação:** A — `CampaignHomeActionButton` JÁ suporta `onClick`; falta só o fio pelo overlay; o contexto de quick actions já é a ponte página→shell (precedente `CampaignQuickActionContextSync/Bridge`). +**Rejeitadas:** B (diálogo não é rota); C (duplica o drawer). + +### Componentes / mudanças + +- **`src/utilities/activityAgendaOmnibox.ts`** (novo): adaptador puro do omnibox para `ActivityAgendaState` — `buildActivityAgendaOmniboxChips`, `buildActivityAgendaOmniboxSuggestionSeeds` (grupos Município / Tag / "Deputado presente" chip bool), `applyActivityAgendaOmniboxSuggestion`, `removeActivityAgendaOmniboxChip`, `clearActivityAgendaOmnibox`. Mesmo shape de `activityOmnibox.ts` (não é twin — outro `State` com 3 dimensões e sem busca/Janela). +- **`ActivityAgendaFilters.tsx`** (reescrita): vira a toolbar — `` com chips/sugestões/`Limpar` (chassis tem clear built-in) + `trailing` com `[ícone carrinho: Planejar giro][ícone "+" : Nova atividade]` **`hidden md:inline-flex`** (mobile: zero botões no corpo). `id="agenda-omnibox"`, `label="Filtrar agenda"`, `placeholder` tipo "Filtrar por município, tag, deputado…". Navegação via `useCampaignListFilterNavigation` + `buildActivityAgendaHref`. +- **`agenda/page.tsx`**: remove o bloco de botões "Planejar giro"/"Nova atividade" (movem para a toolbar); envolve o conteúdo em `` (com `feeds`, `createFeed(label)` = closure com `state`, `revokeFeed`, `canGenerate = hasFilters`); mantém `restrictActivityAgendaState`/redirects intocados. +- **`CalendarFeedDialog.tsx`** (extraído de `CalendarFeedButton.tsx`): diálogo nomear→copiar→revogar, **responsivo** (Dialog desktop / Drawer-sheet mobile via `useIsMobile`, igual ao `CampaignQuickActionsOverlay`); expõe `CalendarFeedDialogProvider` + `useCalendarFeedDialog()` (`{ open, canGenerate }`); quando `!canGenerate`, abre com aviso "Aplique filtros para gerar um link de import" e desabilita "Gerar link". O provider também injeta `openCalendarFeed` no `CampaignQuickActionContext` (merge funcional + cleanup). +- **`CampaignPageChromeContext.tsx`** (extender): adiciona `headerActions: Record` + `setHeaderAction(key, node|null)`; novo componente `SetCampaignHeaderAction({ id, children }): null` (padrão `SetCampaignPageChrome`). +- **`CampaignDesktopHeader.tsx`**: renderiza `Object.values(headerActions)` no cluster à direita, ANTES do sino e do AI (ordem do gate: `[título …][Semana ▾ C95][Link de import][Notificações][IA]`). +- **`AgendaFeedHeaderButton.tsx`** (novo, client): botão-ícone `CalendarIcon`, `title`/tooltip, `disabled={!canGenerate}`, `onClick={open}` — registrado via `SetCampaignHeaderAction id="calendar-feed"` na página (desktop-only porque o `CampaignDesktopHeader` é `md:flex`). +- **Quick actions**: `CampaignQuickAction` ganha `onAction?: () => void`; `CampaignQuickActionsOverlay` repassa `onClick: action.onAction` para o `CampaignHomeActionStrip` (o botão já suporta); `CampaignQuickActionContext` ganha `openCalendarFeed?: () => void`; `resolveActivityQuickActions` (surface `list`) devolve `[new-activity, import-calendar(onAction), plan-tour]` — ordem da intenção. +- **Migration:** sem migration (nenhuma mudança de schema). +- **Access / Consent:** nenhuma mudança — agenda segue `gate: 'staff'`; nenhum opt-in novo (o feed é dado de staff, sem Consent). +- **UI:** Impeccable C/B — consolidar a toolbar seguindo o padrão de `ActivityFilters`/listas; shape → craft → critique → polish leve; tokens `data-theme='campaign'`. + +### Dados → forma (se aplicável) + +- Forma: chips + sugestões agrupadas (padrão omnibox do repo) — "não apresento dados novos, filtro = navegação/URL". Decidido pela intenção (pergunta 3: forma adiada para o padrão existente). + +## Fases verificáveis + +1. **Combobox (traço dominante)** — `activityAgendaOmnibox.ts` + unit tests (espelho de `listOmniboxB128`); reescrever `ActivityAgendaFilters` (omnibox + trailing desktop); página: remover botões avulsos. Verificar: URL canonical inalterada; e2e da agenda reescrito nos seletores (hoje `getByLabel('Município'|'Tag'|'Deputado presente')`). +2. **Feed dialog + header slot + FAB** — extrair `CalendarFeedDialog` (Dialog/sheet); provider + `useCalendarFeedDialog`; extender chrome context (`headerActions` + `SetCampaignHeaderAction`); `AgendaFeedHeaderButton` + header renderiza registrados; quick actions `onAction` + `openCalendarFeed` + `import-calendar`. +3. **Gates** — `pnpm gate:fast` na iteração; rebase em `origin/main`; e2e agenda (atualizar + rodar); `pnpm push` no fechamento. + +## Rabbit holes / Não escopo (engenharia) + +- **Omnibox de busca global da agenda** (texto livre, multi-município, presets de janela) — fora; só as 3 dimensões de hoje, sem `onCommitQuery`. +- **Refatorar `CampaignHomeActionStrip`/`CampaignHomeActionButton`** além do fio mínimo do `onAction` — não. +- **Migrar as outras listas para o omnibox** (municípios etc. já usam) — não. +- **Header vira barra de navegação** — mantém batch pequeno: só `headerActions` registrados + sino + IA. +- **C92/C93**: o ícone reflete apenas o estado habilitado/desabilitado; "gerar feed sem filtros" é a C93 (fora). +- **Não mudar o compose de giro nem o overlay inline (C91).** + +## Riscos e mitigação + +- **`useSearchParams` no layout** — evitado (slot por contexto; o header não lê URL). Não introduz Suspense. +- **Flash de registro do header** — `SetCampaignHeaderAction` usa `useLayoutEffect` (padrão do título); botão aparece ~1 frame após mount; aceitável nas rotas `/campanha/agenda` (nas demais o slot fica vazio → sem poluição). +- **Contexto `openCalendarFeed` undefined antes do mount** — o drawer só abre por gesto humano, bem depois do efeito; `import-calendar` é incluído sempre na surface `list` e abre o sheet mesmo sem filtro (dialog explica), então não há tile morta. +- **Colidir com C95 (Semana ▾ no header)** — registro chaveado por `id`; C94 usa `calendar-feed`, C95 usará outra chave; ordem fixa no header. +- **Regressão e2e** — a única e2e (linhas 120-122) troca selects por interação omnibox; ajustar junto da fase 1. +- **Branch atrás de main (4 commits, C92/B171)** — rebase em `origin/main` no início da execução (skill `rebase-on-main`). + +## Aceite de engenharia + +- [ ] Aceite de produto da intenção ainda coberto (3 pontos + anti-goals) +- [ ] Invariantes AGENTS/engineering-standards (staff gate, URL canonical, sem Consent novo) +- [ ] Testes de domínio: unit do adaptador de agenda (espelho B128); e2e agenda reescrito e verde; int/serviço de feed intocados +- [ ] `pnpm gate:fast`, tsc, lint, prettier, knip, check:cycles verdes diff --git a/playwright.config.ts b/playwright.config.ts index 1b17c023..cccf768a 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -109,6 +109,9 @@ export default defineConfig({ */ reuseExistingServer: false, url: baseURL, + // Local-only tolerance for parallel-worktree load (machine load ~60); CI + // boots the production build and keeps the usual 60s budget. + timeout: process.env.CI ? undefined : 240_000, /* Force the isolated test database into the server process. */ env: { DATABASE_URL: process.env.DATABASE_URL as string, diff --git a/src/app/(campaign)/campanha/(app)/agenda/page.tsx b/src/app/(campaign)/campanha/(app)/agenda/page.tsx index 76a4b27b..718f68f9 100644 --- a/src/app/(campaign)/campanha/(app)/agenda/page.tsx +++ b/src/app/(campaign)/campanha/(app)/agenda/page.tsx @@ -1,6 +1,4 @@ import config from '@payload-config' -import { MapPinnedIcon, PlusIcon } from 'lucide-react' -import Link from 'next/link' import { redirect } from 'next/navigation' import { getPayload } from 'payload' @@ -11,23 +9,21 @@ import { } from '@/app/(campaign)/campanha/actions/calendarFeed' import { ActivityAgenda } from '@/components/campaign/activity/ActivityAgenda' import { ActivityAgendaFilters } from '@/components/campaign/activity/ActivityAgendaFilters' +import { AgendaFeedChrome } from '@/components/campaign/activity/AgendaFeedChrome' import { CampaignListPendingBoundary, CampaignListResults, } from '@/components/campaign/shared/CampaignListPending' import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell' -import { Button } from '@/components/ui/button' import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome' import { loadAccessibleActivityTags } from '@/utilities/activityPageData' import { buildActivityAgendaHref, - buildActivityCreateHref, resolveActivityAgendaUrl, restrictActivityAgendaState, } from '@/utilities/activityUi' import { requireCampaignPageActor } from '@/utilities/campaignPageActor' import { loadMunicipalityOptions } from '@/utilities/campaignRelationOptions' -import { TOUR_COMPOSER_PATH } from '@/utilities/visit/visitPlannerUrl' export const metadata = campaignPageMetadataFromCatalog('agenda') @@ -57,47 +53,37 @@ 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 ( -
- - -
+ ({ + id: f.id, + label: f.label, + createdAt: f.createdAt, + }))} + canGenerate={canGenerateFeed} + onCreateFeed={async (label) => { + 'use server' + return createCalendarFeedLink({ + label, + filterMunicipality: state.municipality, + filterDeputyPresent: state.deputyPresent, + filterTag: state.tag, + }) + }} + onRevokeFeed={async (feedId) => { + 'use server' + return revokeCalendarFeed(feedId) + }} + /> ({ - id: f.id, - label: f.label, - createdAt: f.createdAt, - }))} - onCreateFeed={async (label) => { - 'use server' - const result = await createCalendarFeedLink({ - label, - filterMunicipality: state.municipality, - filterDeputyPresent: state.deputyPresent, - filterTag: state.tag, - }) - return result - }} - onRevokeFeed={async (feedId) => { - 'use server' - return revokeCalendarFeed(feedId) - }} /> diff --git a/src/components/campaign/activity/ActivityAgendaFilters.tsx b/src/components/campaign/activity/ActivityAgendaFilters.tsx index 78009e96..f27f39ca 100644 --- a/src/components/campaign/activity/ActivityAgendaFilters.tsx +++ b/src/components/campaign/activity/ActivityAgendaFilters.tsx @@ -1,140 +1,143 @@ 'use client' -import { useEffect, useState } from 'react' +import { MapPinnedIcon, PlusIcon } from 'lucide-react' +import Link from 'next/link' +import { useMemo, useState } from 'react' -import { CalendarFeedButton } from '@/components/campaign/activity/CalendarFeedButton' +import { CampaignListOmnibox } from '@/components/campaign/shared/CampaignListOmnibox' import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect' import { useCampaignListFilterNavigation } from '@/components/campaign/shared/useCampaignListFilterNavigation' import { Button } from '@/components/ui/button' -import { Checkbox } from '@/components/ui/Checkbox' -import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select' -import { buildActivityAgendaHref, type ActivityAgendaState } from '@/utilities/activityUi' - -type CalendarFeedSummary = { - id: number - label: string - createdAt: string -} +import { ACTIVITY_TOUR_COMPOSER_PATH } from '@/lib/campaignQuickActionPaths' +import { + applyActivityAgendaOmniboxSuggestion, + buildActivityAgendaOmniboxChips, + buildActivityAgendaOmniboxSuggestionSeeds, + clearActivityAgendaOmnibox, + filterActivityAgendaOmniboxSuggestions, + removeActivityAgendaOmniboxChip, + type ActivityAgendaOmniboxAction, +} from '@/utilities/activityAgendaOmnibox' +import { + buildActivityAgendaHref, + buildActivityCreateHref, + type ActivityAgendaState, +} from '@/utilities/activityUi' type ActivityAgendaFiltersProps = { state: ActivityAgendaState municipalityOptions: RelationOption[] knownTags: string[] - feeds?: CalendarFeedSummary[] - onCreateFeed?: ( - label: string, - ) => Promise<{ ok: true; feedUrl: string } | { ok: false; message: string }> - onRevokeFeed?: (feedId: number) => Promise<{ ok: boolean }> } +/** + * Agenda toolbar (C94): one omnibox filtering Município (único) + Tag + + * Deputado presente (chip bool), with the desktop create entries as icon + * buttons beside the bar. Mobile creates live in the FAB drawer, so the + * trailing icons are desktop-only. Filter state stays on the URL. + */ export const ActivityAgendaFilters = ({ state, municipalityOptions, knownTags, - feeds = [], - onCreateFeed, - onRevokeFeed, }: ActivityAgendaFiltersProps) => { const { navigate, isPending } = useCampaignListFilterNavigation({ state, toHref: buildActivityAgendaHref, }) - const [draft, setDraft] = useState(state) + const [query, setQuery] = useState('') - useEffect(() => setDraft(state), [state]) + const municipalityLabelsById = useMemo(() => { + const map = new Map() + for (const option of municipalityOptions) { + if (Number.isSafeInteger(option.id) && option.id > 0) map.set(option.id, option.name) + } + return map + }, [municipalityOptions]) - const update = (next: ActivityAgendaState) => { - setDraft(next) - navigate(next) + const municipalityFilterOptions = useMemo( + () => + municipalityOptions.map((option) => ({ + value: String(option.id), + label: option.name, + })), + [municipalityOptions], + ) + + const chips = useMemo( + () => buildActivityAgendaOmniboxChips({ state, municipalityLabelsById }), + [state, municipalityLabelsById], + ) + + const suggestionSeeds = useMemo( + () => + buildActivityAgendaOmniboxSuggestionSeeds({ + municipalityOptions: municipalityFilterOptions, + knownTags, + }), + [municipalityFilterOptions, knownTags], + ) + + const suggestions = useMemo( + () => filterActivityAgendaOmniboxSuggestions(suggestionSeeds, query), + [suggestionSeeds, query], + ) + + const runAction = (action: ActivityAgendaOmniboxAction) => { + if (action.kind === 'clear') { + setQuery('') + navigate({}) + return + } + navigate(action.state) } - const hasFilters = Boolean(draft.municipality || draft.deputyPresent || draft.tag) - return ( -
-
-
- - { - const municipality = Number(event.target.value) - update({ - ...draft, - municipality: municipality > 0 ? municipality : undefined, - }) - }} - className="w-full **:data-[slot=native-select]:min-h-11" + { + runAction(applyActivityAgendaOmniboxSuggestion({ state, suggestionId })) + }} + onRemoveChip={(chipId) => { + runAction(removeActivityAgendaOmniboxChip({ state, chipId })) + }} + onClearAll={() => { + runAction(clearActivityAgendaOmnibox()) + }} + trailing={ +
+
- -
- - update({ ...draft, tag: event.target.value || undefined })} - className="w-full **:data-[slot=native-select]:min-h-11" + + + + +
- - - - - - {onCreateFeed && onRevokeFeed && ( - - )} -
-

- {isPending ? 'Atualizando os compromissos da agenda.' : 'Filtros da agenda atualizados.'} -

-
+ } + /> ) } diff --git a/src/components/campaign/activity/AgendaFeedChrome.tsx b/src/components/campaign/activity/AgendaFeedChrome.tsx new file mode 100644 index 00000000..85603b9b --- /dev/null +++ b/src/components/campaign/activity/AgendaFeedChrome.tsx @@ -0,0 +1,72 @@ +'use client' + +import { CalendarIcon } from 'lucide-react' +import { useCallback, useEffect, useState } from 'react' + +import { + CalendarFeedDialog, + type CalendarFeedSummary, + type CreateCalendarFeedResult, +} from '@/components/campaign/activity/CalendarFeedDialog' +import { SetCampaignHeaderAction } from '@/components/campaign/shell/CampaignPageChromeContext' +import { useCampaignQuickActionContext } from '@/components/campaign/shell/CampaignQuickActionContext' +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 + 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. + */ +export const AgendaFeedChrome = ({ + feeds, + canGenerate, + onCreateFeed, + onRevokeFeed, +}: AgendaFeedChromeProps) => { + const { setContext } = useCampaignQuickActionContext() + const [open, setOpen] = useState(false) + const openFeed = useCallback(() => setOpen(true), []) + + useEffect(() => { + setContext((current) => ({ ...current, openCalendarFeed: openFeed })) + return () => { + setContext((current) => ({ ...current, openCalendarFeed: undefined })) + } + }, [setContext, openFeed]) + + return ( + <> + + + + + + + ) +} diff --git a/src/components/campaign/activity/CalendarFeedButton.tsx b/src/components/campaign/activity/CalendarFeedButton.tsx deleted file mode 100644 index 5474eace..00000000 --- a/src/components/campaign/activity/CalendarFeedButton.tsx +++ /dev/null @@ -1,216 +0,0 @@ -'use client' - -import { useState } from 'react' - -import { CalendarIcon, CopyIcon, ExternalLinkIcon, Trash2Icon } from 'lucide-react' - -import { Button } from '@/components/ui/button' -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog' -import { Input } from '@/components/ui/input' -import type { ActivityAgendaState } from '@/utilities/activityUi' - -type CalendarFeed = { - id: number - label: string - createdAt: string -} - -type CalendarFeedButtonProps = { - state: ActivityAgendaState - hasFilters: boolean - feeds: CalendarFeed[] - onCreateFeed: ( - label: string, - ) => Promise<{ ok: true; feedUrl: string } | { ok: false; message: string }> - onRevokeFeed: (feedId: number) => Promise<{ ok: boolean }> -} - -export const CalendarFeedButton = ({ - state: _state, - hasFilters, - feeds, - onCreateFeed, - onRevokeFeed, -}: CalendarFeedButtonProps) => { - const [isOpen, setIsOpen] = useState(false) - const [label, setLabel] = useState('') - const [createdUrl, setCreatedUrl] = useState(null) - const [isCreating, setIsCreating] = useState(false) - const [error, setError] = useState(null) - const [copied, setCopied] = useState(false) - - const handleCreate = async () => { - if (!label.trim()) return - setIsCreating(true) - setError(null) - const result = await onCreateFeed(label.trim()) - setIsCreating(false) - if (result.ok) { - setCreatedUrl(result.feedUrl) - setLabel('') - } else { - setError(result.message) - } - } - - const handleCopy = async () => { - if (!createdUrl) return - await navigator.clipboard.writeText(createdUrl) - setCopied(true) - setTimeout(() => setCopied(false), 2000) - } - - const handleRevoke = async (feedId: number) => { - await onRevokeFeed(feedId) - } - - const handleClose = () => { - setIsOpen(false) - setCreatedUrl(null) - setError(null) - setCopied(false) - } - - return ( - <> - - - !open && handleClose()}> - - - Sincronizar com Google Calendar - - Gere um link de import para sincronizar este recorte da agenda com o Google Calendar. - Alterações em Teqo serão refletidas automaticamente. - - - - {createdUrl ? ( -
-
- -
- - -
- {copied &&

Link copiado!

} -
- -
-

Como adicionar ao Google Calendar:

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

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

-
- - {error &&

{error}

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

Seus feeds ativos

-
    - {feeds.map((feed) => ( -
  • - {feed.label} - -
  • - ))} -
-
- )} - -
- - -
-
- )} -
-
- - ) -} diff --git a/src/components/campaign/activity/CalendarFeedDialog.tsx b/src/components/campaign/activity/CalendarFeedDialog.tsx new file mode 100644 index 00000000..912db22c --- /dev/null +++ b/src/components/campaign/activity/CalendarFeedDialog.tsx @@ -0,0 +1,265 @@ +'use client' + +import { useState } from 'react' + +import { CopyIcon, ExternalLinkIcon, Trash2Icon } from 'lucide-react' + +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { + Drawer, + DrawerCloseButton, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, +} from '@/components/ui/Drawer' +import { Input } from '@/components/ui/input' +import { useIsMobile } from '@/hooks/use-mobile' + +export type CalendarFeedSummary = { + id: number + label: string + createdAt: string +} + +export type CreateCalendarFeedResult = + | { ok: true; feedUrl: string } + | { ok: false; message: string } + +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 + onRevokeFeed: (feedId: number) => Promise<{ ok: boolean }> +} + +/** + * 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). + */ +export const CalendarFeedDialog = ({ + open, + onOpenChange, + feeds, + canGenerate, + onCreateFeed, + onRevokeFeed, +}: CalendarFeedDialogProps) => { + const isMobile = useIsMobile() + const [label, setLabel] = useState('') + const [createdUrl, setCreatedUrl] = useState(null) + const [isCreating, setIsCreating] = useState(false) + const [error, setError] = useState(null) + const [copied, setCopied] = useState(false) + + const handleCreate = async () => { + if (!label.trim() || !canGenerate || isCreating) return + setIsCreating(true) + setError(null) + const result = await onCreateFeed(label.trim()) + setIsCreating(false) + if (result.ok) { + setCreatedUrl(result.feedUrl) + setLabel('') + } else { + setError(result.message) + } + } + + const handleCopy = async () => { + if (!createdUrl) return + await navigator.clipboard.writeText(createdUrl) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + const handleRevoke = async (feedId: number) => { + await onRevokeFeed(feedId) + } + + const handleClose = (next: boolean) => { + if (next) return + setCreatedUrl(null) + setError(null) + setCopied(false) + onOpenChange(false) + } + + const body = ( + <> + {createdUrl ? ( +
+
+ +
+ + +
+ {copied &&

Link copiado!

} +
+ +
+

Como adicionar ao Google Calendar:

+
    +
  1. Abra o Google Calendar no computador
  2. +
  3. + No menu lateral, clique em {'"'} + {'+'} + {'"'} ao lado de {'"'}Outras agendas{'"'} +
  4. +
  5. + Selecione {'"'}Por URL{'"'} +
  6. +
  7. + Cole o link acima e clique em {'"'}Adicionar agenda{'"'} +
  8. +
+
+ +
+ {!isMobile ? ( + + ) : null} + +
+
+ ) : ( +
+ {!canGenerate ? ( +

+ Aplique filtros à agenda para gerar um link de import. +

+ ) : null} + +
+ + setLabel(e.target.value)} + placeholder="Ex: Só deputado presente" + maxLength={120} + onKeyDown={(e) => e.key === 'Enter' && handleCreate()} + /> +

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

+
+ + {error &&

{error}

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

Seus feeds ativos

+
    + {feeds.map((feed) => ( +
  • + {feed.label} + +
  • + ))} +
+
+ )} + +
+ {!isMobile ? ( + + ) : null} + +
+
+ )} + + ) + + if (isMobile) { + return ( + + + + Sincronizar com Google Calendar + + Gere um link de import para sincronizar este recorte da agenda com o Google Calendar. + Alterações em Teqo serão refletidas automaticamente. + + +
{body}
+ + Fechar + +
+
+ ) + } + + return ( + + + + Sincronizar com Google Calendar + + Gere um link de import para sincronizar este recorte da agenda com o Google Calendar. + Alterações em Teqo serão refletidas automaticamente. + + + {body} + + + ) +} diff --git a/src/components/campaign/shell/CampaignDesktopHeader.tsx b/src/components/campaign/shell/CampaignDesktopHeader.tsx index b79e9449..c2ae25db 100644 --- a/src/components/campaign/shell/CampaignDesktopHeader.tsx +++ b/src/components/campaign/shell/CampaignDesktopHeader.tsx @@ -3,16 +3,22 @@ import type { ReactNode } from 'react' import { CampaignAIHeaderButton } from '@/components/campaign/shell/ai/CampaignAIHeaderButton' +import { useCampaignHeaderActions } from '@/components/campaign/shell/CampaignPageChromeContext' import { CampaignPageChromeDisplay } from '@/components/campaign/shell/CampaignPageChromeDisplay' import { SidebarTrigger } from '@/components/ui/Sidebar' -export const CampaignDesktopHeader = ({ notificationBell }: { notificationBell?: ReactNode }) => ( -
- - -
- - {notificationBell ?? null} -
-
-) +export const CampaignDesktopHeader = ({ notificationBell }: { notificationBell?: ReactNode }) => { + const headerActions = useCampaignHeaderActions() + + return ( +
+ + +
+ {Object.values(headerActions)} + {notificationBell ?? null} + +
+
+ ) +} diff --git a/src/components/campaign/shell/CampaignPageChromeContext.tsx b/src/components/campaign/shell/CampaignPageChromeContext.tsx index 1f9b84b7..d44ca20a 100644 --- a/src/components/campaign/shell/CampaignPageChromeContext.tsx +++ b/src/components/campaign/shell/CampaignPageChromeContext.tsx @@ -2,6 +2,7 @@ import { createContext, + useCallback, useContext, useLayoutEffect, useMemo, @@ -14,10 +15,19 @@ import { import type { CampaignPageChrome } from '@/lib/campaignPageChrome' import type { CampaignUser } from '@/payload-types' +/** + * Header action nodes registered by pages, rendered in the app header in + * insertion order (C94/C95 shared slot — e.g. the agenda-contextual "Link de + * import"). + */ +export type CampaignHeaderActionsMap = Record + type CampaignPageChromeContextValue = { role: CampaignUser['role'] override: CampaignPageChrome | null setOverride: Dispatch> + headerActions: CampaignHeaderActionsMap + setHeaderAction: (key: string, node: ReactNode | null) => void } const CampaignPageChromeContext = createContext(null) @@ -30,7 +40,24 @@ export const CampaignPageChromeProvider = ({ children: ReactNode }) => { const [override, setOverride] = useState(null) - const value = useMemo(() => ({ role, override, setOverride }), [role, override]) + const [headerActions, setHeaderActions] = useState({}) + + const setHeaderAction = useCallback((key: string, node: ReactNode | null) => { + setHeaderActions((current) => { + const next = { ...current } + if (node === null) { + delete next[key] + } else { + next[key] = node + } + return next + }) + }, []) + + const value = useMemo( + () => ({ role, override, setOverride, headerActions, setHeaderAction }), + [role, override, headerActions, setHeaderAction], + ) return ( @@ -53,6 +80,9 @@ export const useCampaignPageChromeRole = (): CampaignUser['role'] => export const useCampaignPageChromeOverride = (): CampaignPageChrome | null => useCampaignPageChromeContext().override +export const useCampaignHeaderActions = (): CampaignHeaderActionsMap => + useCampaignPageChromeContext().headerActions + export const SetCampaignPageChrome = ({ chrome }: { chrome: CampaignPageChrome }): null => { const { setOverride } = useCampaignPageChromeContext() @@ -69,3 +99,24 @@ export const SetCampaignPageChrome = ({ chrome }: { chrome: CampaignPageChrome } return null } + +/** + * Registers a page-owned control into the app header's right cluster + * (agenda-contextual by nature — only pages render it). Not rendered in place. + */ +export const SetCampaignHeaderAction = ({ + id, + children, +}: { + id: string + children: ReactNode +}): null => { + const setHeaderAction = useCampaignPageChromeContext().setHeaderAction + + useLayoutEffect(() => { + setHeaderAction(id, children) + return () => setHeaderAction(id, null) + }, [setHeaderAction, id, children]) + + return null +} diff --git a/src/components/campaign/shell/CampaignQuickActionsOverlay.tsx b/src/components/campaign/shell/CampaignQuickActionsOverlay.tsx index d45689d3..377cac3a 100644 --- a/src/components/campaign/shell/CampaignQuickActionsOverlay.tsx +++ b/src/components/campaign/shell/CampaignQuickActionsOverlay.tsx @@ -32,9 +32,12 @@ const overlayRetractionClass = (retracted: boolean) => const OverlayActionsChrome = ({ actions, retracted, + onActionClick, }: { actions: readonly CampaignQuickAction[] retracted: boolean + /** Fired after an `onAction` (dialog-style) quick action runs — closes the drawer. */ + onActionClick: () => void }) => { if (actions.length === 0) return null @@ -54,6 +57,12 @@ const OverlayActionsChrome = ({ icon: action.icon, description: action.description, href: action.href, + onClick: action.onAction + ? () => { + action.onAction?.() + onActionClick() + } + : undefined, }))} className="w-full" /> @@ -70,15 +79,17 @@ const OverlaySearchChrome = ({ className }: { className?: string }) => ( const CampaignQuickActionsOverlayBody = ({ actions, + onActionClick, }: { actions: readonly CampaignQuickAction[] + onActionClick: () => void }) => { const { uiFocused: focused } = useHomeSearch() return (
- +
void }) => { const pathname = usePathname() const { context } = useCampaignQuickActionContext() @@ -98,7 +111,7 @@ const CampaignQuickActionsOverlayContent = ({ return ( - + ) } @@ -135,7 +148,10 @@ export const CampaignQuickActionsOverlay = ({ Ações do contexto e busca na campanha
- + onOpenChange(false)} + />
@@ -159,7 +175,10 @@ export const CampaignQuickActionsOverlay = ({ Ações do contexto e busca na campanha
- + onOpenChange(false)} + />
diff --git a/src/lib/activityQuickActions.ts b/src/lib/activityQuickActions.ts index ec7d69c1..636a589d 100644 --- a/src/lib/activityQuickActions.ts +++ b/src/lib/activityQuickActions.ts @@ -1,4 +1,4 @@ -import { ClipboardList, ListChecks, MapPinned, Pencil, Plus } from 'lucide-react' +import { Calendar, ClipboardList, ListChecks, MapPinned, Pencil, Plus } from 'lucide-react' import { CAMPAIGN_WIZARD_ACTION_SLUGS, @@ -23,7 +23,7 @@ const WIZARD_ACTION_IDS: readonly CampaignWizardActionId[] = [ 'register-demand', ] -const listQuickActions = (): readonly CampaignQuickAction[] => [ +const listQuickActions = (context: CampaignQuickActionContext): readonly CampaignQuickAction[] => [ { id: 'new-activity', label: 'Nova atividade', @@ -31,6 +31,17 @@ const listQuickActions = (): readonly CampaignQuickAction[] => [ description: 'Criar caminhada, comício, panfletagem ou outra ação de campanha', href: ACTIVITY_NEW_PATH, }, + ...(context.openCalendarFeed + ? [ + { + id: 'import-calendar', + label: 'Link de import', + icon: Calendar, + description: 'Gerar link para sincronizar este recorte da agenda com o Google Calendar', + onAction: context.openCalendarFeed, + }, + ] + : []), { id: 'plan-tour', label: 'Planejar giro', @@ -101,7 +112,7 @@ export const resolveActivityQuickActions = ( if (!isStaffCampaignRole(role)) return [] if (surface.kind === 'list') { - return listQuickActions() + return listQuickActions(context) } const activitySlug = context.activitySlug ?? surface.activitySlug diff --git a/src/lib/campaignQuickActionContext.ts b/src/lib/campaignQuickActionContext.ts index a3290573..68420972 100644 --- a/src/lib/campaignQuickActionContext.ts +++ b/src/lib/campaignQuickActionContext.ts @@ -10,6 +10,12 @@ export type CampaignQuickActionContext = { activitySlug?: string demandSlug?: string advisorId?: number + /** + * Dialog-style action bridged by the agenda page: opens the calendar-feed + * dialog (C94). A function can live in this client-only context — never + * crossed server→client. + */ + openCalendarFeed?: () => void } export const emptyCampaignQuickActionContext = (): CampaignQuickActionContext => ({}) diff --git a/src/lib/campaignQuickActionTypes.ts b/src/lib/campaignQuickActionTypes.ts index 10cd88be..a07741c0 100644 --- a/src/lib/campaignQuickActionTypes.ts +++ b/src/lib/campaignQuickActionTypes.ts @@ -7,4 +7,6 @@ export type CampaignQuickAction = { icon: LucideIcon description: string href?: string + /** Dialog-style actions (no route); the drawer button fires it instead of navigating. */ + onAction?: () => void } diff --git a/src/utilities/activityAgendaOmnibox.ts b/src/utilities/activityAgendaOmnibox.ts new file mode 100644 index 00000000..490af7c8 --- /dev/null +++ b/src/utilities/activityAgendaOmnibox.ts @@ -0,0 +1,180 @@ +/** + * Agenda omnibox adapter (C94): chips, suggestions and apply/remove over the + * frozen `ActivityAgendaState` URL contract. Pure / client-safe. + * + * The agenda filter surface is deliberately NOT the general activity list + * omnibox (`activityOmnibox.ts` — tabs/search/status): the agenda has three + * dimensions (Município único + Tag + Deputado presente chip bool), no free + * text search and no window presets. Municípios filter by typing (435 rows); + * Tag and "Deputado presente" are empty-query-visible shortcuts. + */ +import { + createOmniboxSuggestionSeed, + filterOmniboxSuggestionSeeds, + type CampaignListOmniboxChip, + type CampaignListOmniboxSuggestion, +} from '@/lib/campaignListOmnibox' +import type { ActivityAgendaState } from '@/utilities/activityUi' + +export type ActivityAgendaOmniboxAction = + | { kind: 'url'; state: ActivityAgendaState } + | { kind: 'clear' } + +export type ActivityAgendaFilterOption = { + value: string + label: string +} + +const chipLabel = (dimension: string, value: string): string => `${dimension}: ${value}` + +export const buildActivityAgendaOmniboxChips = ({ + state, + municipalityLabelsById, +}: { + state: ActivityAgendaState + municipalityLabelsById: ReadonlyMap +}): CampaignListOmniboxChip[] => { + const chips: CampaignListOmniboxChip[] = [] + + if (state.municipality) { + chips.push({ + id: `municipality:${state.municipality}`, + label: chipLabel( + 'Município', + municipalityLabelsById.get(state.municipality) ?? `Município #${state.municipality}`, + ), + }) + } + + if (state.tag) { + chips.push({ id: `tag:${state.tag}`, label: chipLabel('Tag', state.tag) }) + } + + if (state.deputyPresent) { + chips.push({ id: 'deputyPresent', label: 'Deputado presente' }) + } + + return chips +} + +export const buildActivityAgendaOmniboxSuggestionSeeds = ({ + municipalityOptions, + knownTags, +}: { + municipalityOptions: readonly ActivityAgendaFilterOption[] + knownTags?: readonly string[] +}) => { + const seeds: ReturnType[] = [] + + seeds.push( + createOmniboxSuggestionSeed( + { + id: 'deputyPresent', + group: 'Deputado presente', + label: 'Deputado presente', + keywords: ['deputado', 'presente'], + }, + { emptyQueryVisible: true }, + ), + ) + + for (const tag of knownTags ?? []) { + seeds.push( + createOmniboxSuggestionSeed( + { + id: `tag:${tag}`, + group: 'Tag', + label: tag, + keywords: ['tag', 'tipo'], + }, + { emptyQueryVisible: true }, + ), + ) + } + + for (const option of municipalityOptions) { + seeds.push( + createOmniboxSuggestionSeed({ + id: `municipality:${option.value}`, + group: 'Município', + label: option.label, + keywords: ['municipio'], + }), + ) + } + + return seeds +} + +/** + * Filters dimension seeds and drops the shared helper's free-text `q:` row: + * the agenda has no text search, so a dead "Busca: …" option would mislead. + */ +export const filterActivityAgendaOmniboxSuggestions = ( + seeds: ReturnType, + query: string, +): CampaignListOmniboxSuggestion[] => + filterOmniboxSuggestionSeeds(seeds, query).filter((suggestion) => !suggestion.id.startsWith('q:')) + +export const applyActivityAgendaOmniboxSuggestion = ({ + state, + suggestionId, +}: { + state: ActivityAgendaState + suggestionId: string +}): ActivityAgendaOmniboxAction => { + if (suggestionId === 'deputyPresent') { + return { + kind: 'url', + state: state.deputyPresent + ? { ...state, deputyPresent: undefined } + : { ...state, deputyPresent: true }, + } + } + + if (suggestionId.startsWith('tag:')) { + const value = suggestionId.slice(4) + return { + kind: 'url', + state: state.tag === value ? { ...state, tag: undefined } : { ...state, tag: value }, + } + } + + if (suggestionId.startsWith('municipality:')) { + const value = Number(suggestionId.slice(13)) + if (!Number.isSafeInteger(value) || value <= 0) return { kind: 'url', state } + return { + kind: 'url', + state: + state.municipality === value + ? { ...state, municipality: undefined } + : { ...state, municipality: value }, + } + } + + return { kind: 'url', state } +} + +export const removeActivityAgendaOmniboxChip = ({ + state, + chipId, +}: { + state: ActivityAgendaState + chipId: string +}): ActivityAgendaOmniboxAction => { + if (chipId === 'deputyPresent') { + return { kind: 'url', state: { ...state, deputyPresent: undefined } } + } + + if (chipId.startsWith('tag:')) { + return { kind: 'url', state: { ...state, tag: undefined } } + } + + if (chipId.startsWith('municipality:')) { + return { kind: 'url', state: { ...state, municipality: undefined } } + } + + return { kind: 'url', state } +} + +export const clearActivityAgendaOmnibox = (): ActivityAgendaOmniboxAction => ({ kind: 'clear' }) diff --git a/tests/e2e/campaignActivity.e2e.spec.ts b/tests/e2e/campaignActivity.e2e.spec.ts index 352dc872..08696971 100644 --- a/tests/e2e/campaignActivity.e2e.spec.ts +++ b/tests/e2e/campaignActivity.e2e.spec.ts @@ -117,9 +117,19 @@ test.describe('Agenda — calendário operacional', () => { }) .not.toBe(startAt) - await page.getByLabel('Município').selectOption(String(municipality.id)) - await page.getByLabel('Tag').selectOption('Comício') - await page.getByLabel('Deputado presente').check() + const omniboxInput = page.getByRole('combobox', { name: 'Filtrar agenda' }) + + await omniboxInput.click() + await omniboxInput.fill(municipality.name!) + await page.getByRole('option', { name: municipality.name, exact: true }).click() + + await omniboxInput.click() + await omniboxInput.fill('Comício') + await page.getByRole('option', { name: 'Comício', exact: true }).click() + + await omniboxInput.click() + await page.getByRole('option', { name: 'Deputado presente', exact: true }).click() + await expect(page).toHaveURL( `${campaign.baseURL}/campanha/agenda?municipality=${municipality.id}&deputyPresent=1&tag=Com%C3%ADcio`, ) diff --git a/tests/unit/activityAgendaOmnibox.unit.spec.ts b/tests/unit/activityAgendaOmnibox.unit.spec.ts new file mode 100644 index 00000000..5c6e4987 --- /dev/null +++ b/tests/unit/activityAgendaOmnibox.unit.spec.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from 'vitest' + +import { + applyActivityAgendaOmniboxSuggestion, + buildActivityAgendaOmniboxChips, + buildActivityAgendaOmniboxSuggestionSeeds, + clearActivityAgendaOmnibox, + filterActivityAgendaOmniboxSuggestions, + removeActivityAgendaOmniboxChip, +} from '@/utilities/activityAgendaOmnibox' + +const municipalityLabelsById = new Map([ + [12, 'Salvador'], + [34, 'Feira de Santana'], +]) + +const municipalityOptions = [ + { value: '12', label: 'Salvador' }, + { value: '34', label: 'Feira de Santana' }, +] + +describe('agenda omnibox adapter (C94)', () => { + it('builds removable chips for the three dimensions', () => { + const chips = buildActivityAgendaOmniboxChips({ + state: { municipality: 12, tag: 'Comício', deputyPresent: true }, + municipalityLabelsById, + }) + expect(chips.map((chip) => chip.id)).toEqual([ + 'municipality:12', + 'tag:Comício', + 'deputyPresent', + ]) + expect(chips[0]!.label).toBe('Município: Salvador') + expect(chips[1]!.label).toBe('Tag: Comício') + expect(chips[2]!.label).toBe('Deputado presente') + }) + + it('falls back to the numeric id when a municipality label is unknown', () => { + const chips = buildActivityAgendaOmniboxChips({ + state: { municipality: 99 }, + municipalityLabelsById, + }) + expect(chips[0]!.label).toBe('Município: Município #99') + }) + + it('suggests deputado presente and tags on empty query, municípios by typing', () => { + const seeds = buildActivityAgendaOmniboxSuggestionSeeds({ + municipalityOptions, + knownTags: ['Comício', 'Panfletagem'], + }) + + const emptyQuery = filterActivityAgendaOmniboxSuggestions(seeds, '') + expect(emptyQuery.some((entry) => entry.id === 'deputyPresent')).toBe(true) + expect(emptyQuery.some((entry) => entry.id === 'tag:Comício')).toBe(true) + expect(emptyQuery.some((entry) => entry.id === 'municipality:12')).toBe(false) + + const typed = filterActivityAgendaOmniboxSuggestions(seeds, 'feira') + expect(typed.some((entry) => entry.id === 'municipality:34')).toBe(true) + + const grouped = typed.find((entry) => entry.id === 'municipality:34') + expect(grouped?.group).toBe('Município') + }) + + it('never surfaces the free-text Busca row (no text search on the agenda)', () => { + const seeds = buildActivityAgendaOmniboxSuggestionSeeds({ + municipalityOptions, + knownTags: ['Comício'], + }) + const suggestions = filterActivityAgendaOmniboxSuggestions(seeds, 'comí') + expect(suggestions.some((entry) => entry.id.startsWith('q:'))).toBe(false) + }) + + it('toggles deputado presente on and off', () => { + const on = applyActivityAgendaOmniboxSuggestion({ + state: {}, + suggestionId: 'deputyPresent', + }) + expect(on.kind).toBe('url') + if (on.kind === 'url') expect(on.state.deputyPresent).toBe(true) + + const off = applyActivityAgendaOmniboxSuggestion({ + state: { deputyPresent: true }, + suggestionId: 'deputyPresent', + }) + expect(off.kind).toBe('url') + if (off.kind === 'url') expect(off.state.deputyPresent).toBeUndefined() + }) + + it('toggles tag and municipality and removes chips', () => { + const base = { municipality: 12, tag: 'Comício' } + const toggledTag = applyActivityAgendaOmniboxSuggestion({ + state: base, + suggestionId: 'tag:Comício', + }) + if (toggledTag.kind === 'url') expect(toggledTag.state.tag).toBeUndefined() + + const toggledMunicipality = applyActivityAgendaOmniboxSuggestion({ + state: base, + suggestionId: 'municipality:34', + }) + if (toggledMunicipality.kind === 'url') expect(toggledMunicipality.state.municipality).toBe(34) + + const removedMunicipality = removeActivityAgendaOmniboxChip({ + state: { municipality: 12 }, + chipId: 'municipality:12', + }) + if (removedMunicipality.kind === 'url') + expect(removedMunicipality.state.municipality).toBeUndefined() + + const removedDeputy = removeActivityAgendaOmniboxChip({ + state: { deputyPresent: true }, + chipId: 'deputyPresent', + }) + if (removedDeputy.kind === 'url') expect(removedDeputy.state.deputyPresent).toBeUndefined() + }) + + it('ignores invalid suggestion ids and q: leftovers with a no-op url action', () => { + const state = { tag: 'Comício' } + const bogus = applyActivityAgendaOmniboxSuggestion({ state, suggestionId: 'q:whatever' }) + expect(bogus.kind).toBe('url') + if (bogus.kind === 'url') expect(bogus.state.tag).toBe('Comício') + + const invalid = applyActivityAgendaOmniboxSuggestion({ + state, + suggestionId: 'municipality:abc', + }) + expect(invalid.kind).toBe('url') + if (invalid.kind === 'url') expect(invalid.state.tag).toBe('Comício') + }) + + it('clears to an empty agenda state', () => { + const cleared = clearActivityAgendaOmnibox() + expect(cleared.kind).toBe('clear') + }) +}) diff --git a/tests/unit/codebaseConventions.unit.spec.ts b/tests/unit/codebaseConventions.unit.spec.ts index 3968c4b8..1bd0a923 100644 --- a/tests/unit/codebaseConventions.unit.spec.ts +++ b/tests/unit/codebaseConventions.unit.spec.ts @@ -385,6 +385,7 @@ describe('src/utilities top-level is pinned', () => { const pinnedTopLevel = new Set([ 'activityDetailPageData.ts', 'activityDetailTabUi.ts', + 'activityAgendaOmnibox.ts', 'activityFormData.ts', 'activityInlineErrors.ts', 'activityOmnibox.ts',