mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
feat(C94): agenda omnibox + feed no header/FAB
This commit is contained in:
parent
2270bfb84e
commit
e0530a3a90
17 changed files with 1010 additions and 383 deletions
93
docs/plans/c94-agenda-combobox-header-acoes-rapidas-impl.md
Normal file
93
docs/plans/c94-agenda-combobox-header-acoes-rapidas-impl.md
Normal file
|
|
@ -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<br/>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 — `<CampaignListOmnibox>` 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 `<CalendarFeedDialogProvider>` (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<string, ReactNode>` + `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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<CampaignPageShell className="gap-6">
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button asChild variant="outline" className="min-h-11">
|
||||
<Link href={TOUR_COMPOSER_PATH}>
|
||||
<MapPinnedIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Planejar giro
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild className="min-h-11">
|
||||
<Link href={buildActivityCreateHref(state)}>
|
||||
<PlusIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Nova atividade
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<AgendaFeedChrome
|
||||
feeds={feeds.map((f) => ({
|
||||
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)
|
||||
}}
|
||||
/>
|
||||
|
||||
<CampaignListPendingBoundary>
|
||||
<ActivityAgendaFilters
|
||||
state={state}
|
||||
municipalityOptions={municipalityOptions}
|
||||
knownTags={knownTags}
|
||||
feeds={feeds.map((f) => ({
|
||||
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)
|
||||
}}
|
||||
/>
|
||||
<CampaignListResults>
|
||||
<ActivityAgenda state={state} municipalityOptions={municipalityOptions} />
|
||||
|
|
|
|||
|
|
@ -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<number, string>()
|
||||
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 (
|
||||
<section
|
||||
aria-label="Filtros da agenda"
|
||||
aria-busy={isPending}
|
||||
className="rounded-xl border bg-card p-4 transition-opacity data-[pending=true]:opacity-70"
|
||||
data-pending={isPending}
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-[minmax(12rem,1fr)_minmax(11rem,0.7fr)_auto_auto] md:items-end">
|
||||
<div className="grid gap-1.5">
|
||||
<label className="text-sm font-medium" htmlFor="agenda-municipality">
|
||||
Município
|
||||
</label>
|
||||
<NativeSelect
|
||||
id="agenda-municipality"
|
||||
value={draft.municipality ? String(draft.municipality) : ''}
|
||||
onChange={(event) => {
|
||||
const municipality = Number(event.target.value)
|
||||
update({
|
||||
...draft,
|
||||
municipality: municipality > 0 ? municipality : undefined,
|
||||
})
|
||||
}}
|
||||
className="w-full **:data-[slot=native-select]:min-h-11"
|
||||
<CampaignListOmnibox
|
||||
id="agenda-omnibox"
|
||||
label="Filtrar agenda"
|
||||
placeholder="Filtrar por município, tag, deputado presente…"
|
||||
chips={chips}
|
||||
suggestions={suggestions}
|
||||
query={query}
|
||||
onQueryChange={setQuery}
|
||||
isPending={isPending}
|
||||
onSelectSuggestion={(suggestionId) => {
|
||||
runAction(applyActivityAgendaOmniboxSuggestion({ state, suggestionId }))
|
||||
}}
|
||||
onRemoveChip={(chipId) => {
|
||||
runAction(removeActivityAgendaOmniboxChip({ state, chipId }))
|
||||
}}
|
||||
onClearAll={() => {
|
||||
runAction(clearActivityAgendaOmnibox())
|
||||
}}
|
||||
trailing={
|
||||
<div className="hidden items-center gap-2 md:flex">
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="min-w-11"
|
||||
aria-label="Planejar giro"
|
||||
title="Planejar giro"
|
||||
>
|
||||
<NativeSelectOption value="">Todos os municípios</NativeSelectOption>
|
||||
{municipalityOptions.map((option) => (
|
||||
<NativeSelectOption key={option.id} value={String(option.id)}>
|
||||
{option.name}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<label className="text-sm font-medium" htmlFor="agenda-tag">
|
||||
Tag
|
||||
</label>
|
||||
<NativeSelect
|
||||
id="agenda-tag"
|
||||
value={draft.tag ?? ''}
|
||||
onChange={(event) => update({ ...draft, tag: event.target.value || undefined })}
|
||||
className="w-full **:data-[slot=native-select]:min-h-11"
|
||||
<Link href={ACTIVITY_TOUR_COMPOSER_PATH}>
|
||||
<MapPinnedIcon className="size-5" aria-hidden />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
size="icon"
|
||||
className="min-w-11"
|
||||
aria-label="Nova atividade"
|
||||
title="Nova atividade"
|
||||
>
|
||||
<NativeSelectOption value="">Todas as tags</NativeSelectOption>
|
||||
{knownTags.map((tag) => (
|
||||
<NativeSelectOption key={tag} value={tag}>
|
||||
{tag}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
<Link href={buildActivityCreateHref(state)}>
|
||||
<PlusIcon className="size-5" aria-hidden />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<label className="flex min-h-11 items-center gap-3 rounded-lg border px-3 text-sm font-medium">
|
||||
<Checkbox
|
||||
checked={Boolean(draft.deputyPresent)}
|
||||
onCheckedChange={(checked) =>
|
||||
update({ ...draft, deputyPresent: checked === true ? true : undefined })
|
||||
}
|
||||
/>
|
||||
Deputado presente
|
||||
</label>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="min-h-11"
|
||||
disabled={!hasFilters}
|
||||
onClick={() => update({})}
|
||||
>
|
||||
Limpar filtros
|
||||
</Button>
|
||||
|
||||
{onCreateFeed && onRevokeFeed && (
|
||||
<CalendarFeedButton
|
||||
state={state}
|
||||
hasFilters={hasFilters}
|
||||
feeds={feeds}
|
||||
onCreateFeed={onCreateFeed}
|
||||
onRevokeFeed={onRevokeFeed}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<p className="sr-only" aria-live="polite">
|
||||
{isPending ? 'Atualizando os compromissos da agenda.' : 'Filtros da agenda atualizados.'}
|
||||
</p>
|
||||
</section>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
72
src/components/campaign/activity/AgendaFeedChrome.tsx
Normal file
72
src/components/campaign/activity/AgendaFeedChrome.tsx
Normal file
|
|
@ -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<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.
|
||||
*/
|
||||
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 (
|
||||
<>
|
||||
<SetCampaignHeaderAction id="calendar-feed">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
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}
|
||||
onClick={openFeed}
|
||||
>
|
||||
<CalendarIcon className="size-5" aria-hidden />
|
||||
</Button>
|
||||
</SetCampaignHeaderAction>
|
||||
|
||||
<CalendarFeedDialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
feeds={feeds}
|
||||
canGenerate={canGenerate}
|
||||
onCreateFeed={onCreateFeed}
|
||||
onRevokeFeed={onRevokeFeed}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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<string | null>(null)
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="min-h-11"
|
||||
disabled={!hasFilters}
|
||||
onClick={() => setIsOpen(true)}
|
||||
title={!hasFilters ? 'Aplique filtros para gerar um link de import' : undefined}
|
||||
>
|
||||
<CalendarIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Link de import
|
||||
</Button>
|
||||
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<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.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{createdUrl ? (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Link de import</label>
|
||||
<div className="flex gap-2">
|
||||
<Input value={createdUrl} readOnly className="font-mono text-xs" />
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleCopy}
|
||||
aria-label="Copiar link"
|
||||
>
|
||||
<CopyIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{copied && <p className="text-xs text-green-600">Link copiado!</p>}
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-muted p-4 text-sm">
|
||||
<p className="font-medium mb-2">Como adicionar ao Google Calendar:</p>
|
||||
<ol className="list-decimal list-inside space-y-1 text-muted-foreground">
|
||||
<li>Abra o Google Calendar no computador</li>
|
||||
<li>
|
||||
No menu lateral, clique em {'"'}+{'"'} ao lado de {'"'}Outras agendas{'"'}
|
||||
</li>
|
||||
<li>
|
||||
Selecione {'"'}Por URL{'"'}
|
||||
</li>
|
||||
<li>
|
||||
Cole o link acima e clique em {'"'}Adicionar agenda{'"'}
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={handleClose}>
|
||||
Fechar
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<a
|
||||
href="https://calendar.google.com/calendar/u/0/r/settings/addbyurl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<ExternalLinkIcon className="h-4 w-4 mr-2" />
|
||||
Abrir Google Calendar
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="feed-label" className="text-sm font-medium">
|
||||
Nome do feed
|
||||
</label>
|
||||
<Input
|
||||
id="feed-label"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder="Ex: Só deputado presente"
|
||||
maxLength={120}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleCreate()}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Um nome para identificar este feed (ex: {'"'}Agenda do candidato{'"'}, {'"'}
|
||||
Reuniões em Salvador{'"'})
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
|
||||
{feeds.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">Seus feeds ativos</p>
|
||||
<ul className="space-y-1">
|
||||
{feeds.map((feed) => (
|
||||
<li
|
||||
key={feed.id}
|
||||
className="flex items-center justify-between rounded-lg border px-3 py-2 text-sm"
|
||||
>
|
||||
<span>{feed.label}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRevoke(feed.id)}
|
||||
aria-label={`Revogar feed ${feed.label}`}
|
||||
>
|
||||
<Trash2Icon className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={handleClose}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="button" onClick={handleCreate} disabled={!label.trim() || isCreating}>
|
||||
{isCreating ? 'Gerando...' : 'Gerar link'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
265
src/components/campaign/activity/CalendarFeedDialog.tsx
Normal file
265
src/components/campaign/activity/CalendarFeedDialog.tsx
Normal file
|
|
@ -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<CreateCalendarFeedResult>
|
||||
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<string | null>(null)
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const [error, setError] = useState<string | null>(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 ? (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="feed-url">
|
||||
Link de import
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Input id="feed-url" value={createdUrl} readOnly className="font-mono text-xs" />
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleCopy}
|
||||
aria-label="Copiar link"
|
||||
>
|
||||
<CopyIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{copied && <p className="text-xs text-green-600">Link copiado!</p>}
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-muted p-4 text-sm">
|
||||
<p className="mb-2 font-medium">Como adicionar ao Google Calendar:</p>
|
||||
<ol className="list-inside list-decimal space-y-1 text-muted-foreground">
|
||||
<li>Abra o Google Calendar no computador</li>
|
||||
<li>
|
||||
No menu lateral, clique em {'"'}
|
||||
{'+'}
|
||||
{'"'} ao lado de {'"'}Outras agendas{'"'}
|
||||
</li>
|
||||
<li>
|
||||
Selecione {'"'}Por URL{'"'}
|
||||
</li>
|
||||
<li>
|
||||
Cole o link acima e clique em {'"'}Adicionar agenda{'"'}
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
{!isMobile ? (
|
||||
<Button type="button" variant="outline" onClick={() => handleClose(false)}>
|
||||
Fechar
|
||||
</Button>
|
||||
) : null}
|
||||
<Button asChild>
|
||||
<a
|
||||
href="https://calendar.google.com/calendar/u/0/r/settings/addbyurl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<ExternalLinkIcon className="mr-2 h-4 w-4" />
|
||||
Abrir Google Calendar
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<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
|
||||
</label>
|
||||
<Input
|
||||
id="feed-label"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder="Ex: Só deputado presente"
|
||||
maxLength={120}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleCreate()}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Um nome para identificar este feed (ex: {'"'}Agenda do candidato{'"'}, {'"'}
|
||||
Reuniões em Salvador{'"'})
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
|
||||
{feeds.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">Seus feeds ativos</p>
|
||||
<ul className="space-y-1">
|
||||
{feeds.map((feed) => (
|
||||
<li
|
||||
key={feed.id}
|
||||
className="flex items-center justify-between rounded-lg border px-3 py-2 text-sm"
|
||||
>
|
||||
<span>{feed.label}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRevoke(feed.id)}
|
||||
aria-label={`Revogar feed ${feed.label}`}
|
||||
>
|
||||
<Trash2Icon className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
{!isMobile ? (
|
||||
<Button type="button" variant="outline" onClick={() => handleClose(false)}>
|
||||
Cancelar
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleCreate}
|
||||
disabled={!label.trim() || !canGenerate || isCreating}
|
||||
>
|
||||
{isCreating ? 'Gerando...' : 'Gerar link'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={handleClose} showSwipeHandle>
|
||||
<DrawerContent className="max-h-[85vh]">
|
||||
<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.
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<div className="overflow-y-auto px-4 pb-2">{body}</div>
|
||||
<DrawerFooter className="border-t">
|
||||
<DrawerCloseButton className="w-full">Fechar</DrawerCloseButton>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<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.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{body}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 }) => (
|
||||
<header className="hidden min-h-11 shrink-0 items-center gap-3 border-b border-border px-4 md:flex print:hidden">
|
||||
<SidebarTrigger />
|
||||
<CampaignPageChromeDisplay layout="desktop" className="flex-1" />
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||||
<CampaignAIHeaderButton />
|
||||
{notificationBell ?? null}
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
export const CampaignDesktopHeader = ({ notificationBell }: { notificationBell?: ReactNode }) => {
|
||||
const headerActions = useCampaignHeaderActions()
|
||||
|
||||
return (
|
||||
<header className="hidden min-h-11 shrink-0 items-center gap-3 border-b border-border px-4 md:flex print:hidden">
|
||||
<SidebarTrigger />
|
||||
<CampaignPageChromeDisplay layout="desktop" className="flex-1" />
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||||
{Object.values(headerActions)}
|
||||
{notificationBell ?? null}
|
||||
<CampaignAIHeaderButton />
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, ReactNode>
|
||||
|
||||
type CampaignPageChromeContextValue = {
|
||||
role: CampaignUser['role']
|
||||
override: CampaignPageChrome | null
|
||||
setOverride: Dispatch<SetStateAction<CampaignPageChrome | null>>
|
||||
headerActions: CampaignHeaderActionsMap
|
||||
setHeaderAction: (key: string, node: ReactNode | null) => void
|
||||
}
|
||||
|
||||
const CampaignPageChromeContext = createContext<CampaignPageChromeContextValue | null>(null)
|
||||
|
|
@ -30,7 +40,24 @@ export const CampaignPageChromeProvider = ({
|
|||
children: ReactNode
|
||||
}) => {
|
||||
const [override, setOverride] = useState<CampaignPageChrome | null>(null)
|
||||
const value = useMemo(() => ({ role, override, setOverride }), [role, override])
|
||||
const [headerActions, setHeaderActions] = useState<CampaignHeaderActionsMap>({})
|
||||
|
||||
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 (
|
||||
<CampaignPageChromeContext.Provider value={value}>
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="flex min-h-0 flex-col">
|
||||
<div className="order-1 md:order-2">
|
||||
<OverlayActionsChrome actions={actions} retracted={focused} />
|
||||
<OverlayActionsChrome actions={actions} retracted={focused} onActionClick={onActionClick} />
|
||||
</div>
|
||||
<OverlaySearchChrome
|
||||
className={cn('order-2 min-w-0 md:order-1', focused ? 'mt-0 pt-4' : 'mt-4 md:mt-0 md:mb-4')}
|
||||
|
|
@ -89,8 +100,10 @@ const CampaignQuickActionsOverlayBody = ({
|
|||
|
||||
const CampaignQuickActionsOverlayContent = ({
|
||||
actions,
|
||||
onActionClick,
|
||||
}: {
|
||||
actions: readonly CampaignQuickAction[]
|
||||
onActionClick: () => void
|
||||
}) => {
|
||||
const pathname = usePathname()
|
||||
const { context } = useCampaignQuickActionContext()
|
||||
|
|
@ -98,7 +111,7 @@ const CampaignQuickActionsOverlayContent = ({
|
|||
|
||||
return (
|
||||
<HomeSearchExcludeProvider value={excludeContext}>
|
||||
<CampaignQuickActionsOverlayBody actions={actions} />
|
||||
<CampaignQuickActionsOverlayBody actions={actions} onActionClick={onActionClick} />
|
||||
</HomeSearchExcludeProvider>
|
||||
)
|
||||
}
|
||||
|
|
@ -135,7 +148,10 @@ export const CampaignQuickActionsOverlay = ({
|
|||
Ações do contexto e busca na campanha
|
||||
</DrawerDescription>
|
||||
<div className="flex flex-col overflow-y-auto px-4 pb-[max(0.75rem,env(safe-area-inset-bottom))] pt-3">
|
||||
<CampaignQuickActionsOverlayContent actions={actions} />
|
||||
<CampaignQuickActionsOverlayContent
|
||||
actions={actions}
|
||||
onActionClick={() => onOpenChange(false)}
|
||||
/>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
|
@ -159,7 +175,10 @@ export const CampaignQuickActionsOverlay = ({
|
|||
<DialogDescription>Ações do contexto e busca na campanha</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col overflow-y-auto">
|
||||
<CampaignQuickActionsOverlayContent actions={actions} />
|
||||
<CampaignQuickActionsOverlayContent
|
||||
actions={actions}
|
||||
onActionClick={() => onOpenChange(false)}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 => ({})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
180
src/utilities/activityAgendaOmnibox.ts
Normal file
180
src/utilities/activityAgendaOmnibox.ts
Normal file
|
|
@ -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<number, string>
|
||||
}): 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<typeof createOmniboxSuggestionSeed>[] = []
|
||||
|
||||
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<typeof buildActivityAgendaOmniboxSuggestionSeeds>,
|
||||
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' })
|
||||
|
|
@ -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`,
|
||||
)
|
||||
|
|
|
|||
135
tests/unit/activityAgendaOmnibox.unit.spec.ts
Normal file
135
tests/unit/activityAgendaOmnibox.unit.spec.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
applyActivityAgendaOmniboxSuggestion,
|
||||
buildActivityAgendaOmniboxChips,
|
||||
buildActivityAgendaOmniboxSuggestionSeeds,
|
||||
clearActivityAgendaOmnibox,
|
||||
filterActivityAgendaOmniboxSuggestions,
|
||||
removeActivityAgendaOmniboxChip,
|
||||
} from '@/utilities/activityAgendaOmnibox'
|
||||
|
||||
const municipalityLabelsById = new Map<number, string>([
|
||||
[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')
|
||||
})
|
||||
})
|
||||
|
|
@ -385,6 +385,7 @@ describe('src/utilities top-level is pinned', () => {
|
|||
const pinnedTopLevel = new Set<string>([
|
||||
'activityDetailPageData.ts',
|
||||
'activityDetailTabUi.ts',
|
||||
'activityAgendaOmnibox.ts',
|
||||
'activityFormData.ts',
|
||||
'activityInlineErrors.ts',
|
||||
'activityOmnibox.ts',
|
||||
|
|
|
|||
Loading…
Reference in a new issue