Merge pull request #536 from fsolla/C105-agenda-tags-para-agrupar-compromissos-filtro-por-tag-na

feat(C105): tags na agenda — sheet, rótulo no evento e prefill do Mais detalhes
This commit is contained in:
Francisco Solla 2026-08-10 00:58:56 -03:00 committed by GitHub
commit 8c79eddf96
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 443 additions and 102 deletions

View file

@ -0,0 +1,86 @@
# Impl: Agenda — tags para agrupar compromissos + filtro por tag na omnibox
Status: aprovado
Atualizado em: 2026-08-09
Issue: #506
Intenção: docs/plans/agenda-tags-eventos.md
Appetite restante: herdado (~1 dia eng)
## Leitura da intenção
- **Outcome:** cada equipe agrupa os compromissos do jeito dela: tags livres no sheet de criação (chips, texto livre, sugestões das já usadas), rótulo leve no evento do calendário, e a agenda filtra por tag dentro da filter omnibox existente (C94), combinando com os demais filtros e persistindo no estado/URL como eles.
- **O que NÃO negociar:** tag não é `Contact` nem a coleção `Tag` dos posts; texto livre (sugestões são autocomplete, não lista obrigatória); filtro é **dimensão da omnibox existente** (não barra/chips separados); liderança não acessa a agenda (inalterado); sem regressão em criação com horário, remanejo por arrasto (C15), filtros C94 e "Mais detalhes" (C91).
- **O que reavaliar:** a hipótese de direção diz "a tag entra como opção da omnibox" — **isso já foi entregue pelo C94** (2026-08-09): `ActivityAgendaState.tag`, sugestões de `knownTags` (vazias visíveis), chips removíveis, `buildActivityAgendaWhere` com `tags: { contains }`, `restrictActivityAgendaState` por tags acessíveis, `loadAccessibleActivityTags` na página. O gap real do C105 está em: **(1)** campo de tags no sheet inline (`ActivityInlineCreate`), **(2)** rótulo de tag no evento do calendário, **(3)** tags via prefill do "Mais detalhes" (`buildActivityCreateHref`/`parseActivityCreatePrefill`), **(4)** threading de `knownTags` da página da agenda até o sheet.
## Abordagem recomendada
```mermaid
flowchart LR
Page[agenda/page.tsx] -- knownTags --> Agenda[ActivityAgenda]
Agenda -- knownTags --> Sheet[ActivityInlineCreate]
Sheet --> TagInput[ActivityTagInput (extraído do ActivityForm)]
Sheet -- tagsJson --> Action[createActivityInline]
Sheet -- tags no href --> Prefill[buildActivityCreateHref]
Prefill --> Nova[/atividades/nova/]
Nova --> Form[ActivityForm → TagInput initialTags]
Agenda -- extendedProps.tags --> Event[renderEventContent → rótulo leve]
```
**Opções consideradas:**
- **A)** Reusar o `TagInput` já existente em `ActivityForm.tsx` (hoje privado), extraindo-o para um componente compartilhado `src/components/campaign/activity/ActivityTagInput.tsx`, usado pelo sheet e pelo form.
- **B)** Escrever um segundo campo de tags no sheet (duplicar).
- **C)** Levar tags ao evento só via `title` (concatenar no texto) e pular prefill/mais detalhes.
**Recomendação: A (+ C para o rótulo? não — rótulo dedicado).** A: 2 call sites com comportamento idêntico (chips, dedup, limite, datalist de sugestões) → extrair é DRY de conhecimento; a extração é refactor preservador de comportamento (mesmo hidden input `tagsJson`, mesmo limite `MAX_ACTIVITY_TAGS`/`MAX_ACTIVITY_TAG_LENGTH`), com adições retrocompatíveis: `name` prop (default `tagsJson`), `useId()` no datalist (2 instâncias simultâneas nunca acontecem, mas id fixo é risco barato de eliminar) e `onChange` opcional para o href do "Mais detalhes" (o sheet constrói o href em render a partir de estado React, não de FormData).
**Rejeitadas:** B porque duplicaria dedup/limites/datalist e os dois campos divergiriam; C para o rótulo porque o aceite pede "rótulo leve de tag" no evento e misturar no título degrada busca/leitura (o `title` é usado em busca da lista e notificações).
**Divergência da hipótese de direção da intenção:** a intenção (rascunho 2026-08-09) foi escrita antes do C94 entregar a dimensão Tag da omnibox; o impl plan assume isso e não reimplementa nada do filtro.
### Componentes / mudanças
- **`ActivityTagInput`** (`src/components/campaign/activity/ActivityTagInput.tsx`, NOVO): `TagInput` extraído de `ActivityForm.tsx` com as adições acima. Mesma UI (chips `bg-secondary`, botão ×, input com datalist, Enter/vírgula/Backspace, blur commit), hidden input `tagsJson`, `FieldDescription` ("Classificação livre do compromisso…").
- **`ActivityInlineCreate`** (`src/components/campaign/activity/ActivityInlineCreate.tsx`): nova prop `knownTags?: string[]`; campo "Tags" (TagInput) após Local; submit envia `tags` parseados de `tagsJson` (parser inline espelhando `parseInlineResponsibles`, fail-closed); "Mais detalhes" inclui `tags` no prefill; estado espelho via `onChange` para o href.
- **`ActivityAgenda`** (`src/components/campaign/activity/ActivityAgenda.tsx`): nova prop `knownTags?: string[]` repassada ao sheet; `toEventInput` ganha `tags` em `extendedProps`; `renderEventContent` renderiza linha de tags (≤2 + "+N") apenas em views timeGrid (week/day) e list, escondida no mês (denso) e no mobile (mesma media query da location).
- **`ActivityAgenda.css`**: `.activity-agenda-event-tags` (muted, ellipsis, oculto em ≤767px junto com a location).
- **`agenda/page.tsx`**: passa `knownTags` ao `ActivityAgenda` (já carrega `loadAccessibleActivityTags`).
- **`activityUi.ts`**: `ActivityCreatePrefill.tags?: string[]`; `buildActivityCreateHref` serializa `tags` como **params repetidos** (`tags=a&tags=b` — tag livre pode conter vírgula, então nada de join por vírgula; JSON no URL é feio); `parseActivityCreatePrefill` lê `getAll('tags')` com fail-closed (trims, drop > `MAX_ACTIVITY_TAG_LENGTH`, cap `MAX_ACTIVITY_TAGS` — mesmo padrão do `title` bounded).
- **`ActivityForm.tsx`**: usa o componente extraído; `initialTags={initialValues?.tags ?? activity?.tags ?? []}`.
- **Migration:** NÃO — o campo `tags` (hasMany text) já existe na collection desde C14; o filtro já filtra; sem schema/access/Consent novos.
### Dados → forma (se aplicável)
- Forma escolhida: rótulo textual leve (linha muted, `#tag`), não badge colorido — tag não tem identidade visual (corte da intenção); o filtro ativo já é o destaque. Rejeitadas: chips/badges por tag no evento (poluição visual em slots de 30 min), ocultar completamente (o aceite pede o rótulo).
## Fases verificáveis
1. **Utilities/prefill** — `ActivityCreatePrefill.tags` + `buildActivityCreateHref` + `parseActivityCreatePrefill`; testes unit em `activityUi.unit.spec.ts` (round-trip, limites, fail-closed). `pnpm test tests/unit/activityUi.unit.spec.ts`.
2. **Extração + sheet + threading** — `ActivityTagInput.tsx`, `ActivityForm` usando o extraído, `ActivityInlineCreate` com campo/knownTags/submit/href, `ActivityAgenda` + página repassando `knownTags`; testes em `activityInlineCreate.unit.spec.tsx` (chip por Enter, save com tags, href com tags, sugestões do datalist). `pnpm test tests/unit/activityInlineCreate.unit.spec.tsx`.
3. **Rótulo no evento** — `extendedProps.tags` + render condicional por view + CSS; teste de render no `activityAgendaInteractions.unit.spec.tsx` se o harness permitir (senão teste do view model já cobre `tags`).
4. **Gates** — `pnpm gate:fast` na iteração; entrega com `pnpm push`.
## Rabbit holes / Não escopo (engenharia)
- Não tocar no chassis da omnibox compartilhada (a dimensão Tag já está lá; nada das demais listas muda).
- Não mudar `MAX_ACTIVITY_TAGS`/`MAX_ACTIVITY_TAG_LENGTH` (ver Riscos — divergência da recomendação da intenção).
- Não levar tags ao feed iCal (C92–C96 já usam `filterTag`/descrição; fora de escopo da intenção).
- Não criar cores/taxonomia por tag; sem admin.
### Adiado com gatilho (triage /simplify, 2026-08-09)
- **Normalização de tags unificada.** O cleanup da sessão removeu a 3ª cópia (`parseInlineTags` — o sheet agora lê do estado espelho), mas restam dois parsers com política levemente diferente: `parseTagsFormData` (`src/utilities/activityFormData.ts`, server, slice, sem dedup) e `parseActivityPrefillTags` (`src/utilities/activityUi.ts`, URL, drop, dedup). **Gatilho:** um 3º ponto de parse de tags OU qualquer edição em `parseTagsFormData` → extrair `normalizeActivityTags` puro em `src/lib/schemas/activity.ts` com política por fronteira.
- **Footer sticky do sheet** (scrola junto em viewports baixos): **Gatilho:** C103 (in-progress) remodela o sheet de criação — avaliar lá, não aqui.
## Riscos e mitigação
- **Limites 3 tags / ~24 chars da intenção vs 20/80 existentes (C14).** A intenção marca a recomendação como "(assumido — validar)" e "ajustável depois"; o código shipped usa 20/80 compartilhados com o composer de giro (C14), o parse de URL (`activityTag`) e o schema. Apertar agora seria regressão em comportamento shipped e quebraria tags existentes > 24 chars (o filtro as descartaria do URL). **Decisão proposta: manter 20/80**; "poucas tags" é satisfeito pela affordance da UI. Ajuste futuro é barato (constante única) — registrado como decisão do gate.
- **Rótulo poluindo visões densas (mês).** Mitigação: render só em timeGrid/list + oculto no mobile (padrão da location); truncado com ellipsis e "+N".
- **Conflito de merge com C103/C104** (in-progress em outros worktrees, mesma superfície — sheet). C105 toca `ActivityInlineCreate` de forma aditiva (campo novo + props); resolução de conflito no merge sequencial de main. Sem dependência de código deles.
- **Extração do TagInput mudar o form cheio.** Refactor preservador: mesmos props nomeados, mesmo hidden input, mesmo datalist; `pnpm test` + gates pegam drift; o form cheio não tem teste unit próprio hoje (coberto por e2e/regressão).
## Aceite de engenharia
- [x] Aceite de produto da intenção ainda coberto (filtro já entregue por C94; sheet/rótulo/prefill entregues aqui)
- [x] Invariantes AGENTS/engineering-standards (sem migration, sem access/Consent, ids em inglês, copy pt-BR)
- [x] Testes de domínio previstos (unit: prefill tags, inline create com tags, rótulo)

View file

@ -87,7 +87,11 @@ export default async function AgendaPage({ searchParams }: AgendaPageProps) {
knownTags={knownTags}
/>
<CampaignListResults>
<ActivityAgenda state={state} municipalityOptions={municipalityOptions} />
<ActivityAgenda
state={state}
municipalityOptions={municipalityOptions}
knownTags={knownTags}
/>
</CampaignListResults>
</CampaignListPendingBoundary>
</CampaignPageShell>

View file

@ -90,7 +90,8 @@
}
.activity-agenda-event-time,
.activity-agenda-event-location {
.activity-agenda-event-location,
.activity-agenda-event-tags {
overflow: hidden;
font-size: 0.6875rem;
opacity: 0.82;
@ -202,7 +203,8 @@
flex-direction: column;
}
.activity-agenda-event-location {
.activity-agenda-event-location,
.activity-agenda-event-tags {
display: none;
}
}

View file

@ -57,6 +57,7 @@ type AgendaEventProps = {
deputyPresent: boolean
municipalityName: string | null
locality: string | null
tags: string[]
}
const eventColors: Record<ActivityStatus, { color: string; contrastColor: string }> = {
@ -95,12 +96,28 @@ const toEventInput = (event: ActivityAgendaEvent): EventInput => ({
deputyPresent: event.deputyPresent,
municipalityName: event.municipality?.name ?? null,
locality: event.locality,
tags: event.tags,
} satisfies AgendaEventProps,
})
const renderEventContent = ({ event, timeText }: EventDisplayInfo) => {
/**
* C105 — the tag row is a light label, not a badge: tag has no visual
* identity (the active filter is the highlight). Two tags plus a "+N" cap
* the row so a dense slot never floods.
*/
export const tagsLabel = (tags: readonly string[]): string | null => {
if (tags.length === 0) return null
const shown = tags.slice(0, 2)
const overflow = tags.length - shown.length
const label = shown.map((tag) => `#${tag}`).join(' ')
return overflow > 0 ? `${label} +${overflow}` : label
}
const renderEventContent = ({ event, timeText, view }: EventDisplayInfo) => {
const props = event.extendedProps as AgendaEventProps
const location = [props.municipalityName, props.locality].filter(Boolean).join(' · ')
// The tag row only renders outside the dense month grid.
const tags = view.type.startsWith('dayGrid') ? null : tagsLabel(props.tags)
return (
<div className="activity-agenda-event-content">
@ -115,6 +132,7 @@ const renderEventContent = ({ event, timeText }: EventDisplayInfo) => {
</div>
<span className="activity-agenda-event-title">{event.title}</span>
{location ? <span className="activity-agenda-event-location">{location}</span> : null}
{tags ? <span className="activity-agenda-event-tags">{tags}</span> : null}
<span className="activity-agenda-event-status">{activityStatusLabels[props.status]}</span>
</div>
)
@ -123,9 +141,11 @@ const renderEventContent = ({ event, timeText }: EventDisplayInfo) => {
export const ActivityAgenda = ({
state,
municipalityOptions = [],
knownTags,
}: {
state: ActivityAgendaState
municipalityOptions?: RelationOption[]
knownTags?: string[]
}) => {
const containerRef = useRef<HTMLDivElement>(null)
const calendarRef = useRef<CalendarRef>(null)
@ -463,6 +483,7 @@ export const ActivityAgenda = ({
isNarrow={isNarrow}
agendaState={state}
municipalityOptions={municipalityOptions}
knownTags={knownTags}
onClose={() => setCreateDraft(null)}
onCreated={() => {
setCreateDraft(null)

View file

@ -1,9 +1,10 @@
'use client'
import Link from 'next/link'
import { useActionState, useState } from 'react'
import { useActionState } from 'react'
import { ActivityDemandFields } from '@/components/campaign/activity/ActivityDemandFields'
import { ActivityTagInput } from '@/components/campaign/activity/ActivityTagInput'
import { ActivityTaskFields } from '@/components/campaign/activity/ActivityTaskFields'
import type { ContactComboboxOption } from '@/components/campaign/shared/ContactCombobox'
import {
@ -18,13 +19,12 @@ import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Checkbox } from '@/components/ui/Checkbox'
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from '@/components/ui/field'
import { Field, FieldError, FieldGroup, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { Spinner } from '@/components/ui/Spinner'
import { Textarea } from '@/components/ui/textarea'
import { formatIsoAsBahiaDateTimeInput } from '@/lib/campaignTime'
import { MAX_ACTIVITY_TAG_LENGTH, MAX_ACTIVITY_TAGS } from '@/lib/schemas/activity'
import type { ActivityCreatePrefill } from '@/utilities/activityUi'
import type { ActivityFormViewModel } from '@/utilities/activityViewModels'
import { fieldError } from '@/utilities/campaignFormFields'
@ -54,88 +54,6 @@ export type ActivityFormFieldsProps = {
knownTags?: string[]
}
const TagInput = ({
initialTags = [],
knownTags = [],
error,
}: {
initialTags?: string[]
knownTags?: string[]
error?: string
}) => {
const [tags, setTags] = useState<string[]>(initialTags)
const [input, setInput] = useState('')
const datalistId = 'activity-tags-datalist'
const addTag = (raw: string) => {
const trimmed = raw.trim().slice(0, MAX_ACTIVITY_TAG_LENGTH)
if (!trimmed) return
if (tags.includes(trimmed)) return
if (tags.length >= MAX_ACTIVITY_TAGS) return
setTags((current) => [...current, trimmed])
setInput('')
}
const removeTag = (tag: string) => {
setTags((current) => current.filter((entry) => entry !== tag))
}
return (
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-2">
{tags.map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1 rounded-md bg-secondary px-2 py-1 text-xs"
>
{tag}
<button
type="button"
onClick={() => removeTag(tag)}
className="rounded-sm opacity-70 hover:opacity-100"
aria-label={`Remover tag ${tag}`}
>
×
</button>
</span>
))}
{tags.length < MAX_ACTIVITY_TAGS ? (
<Input
value={input}
onChange={(event) => setInput(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ',') {
event.preventDefault()
addTag(input)
} else if (event.key === 'Backspace' && !input && tags.length > 0) {
setTags((current) => current.slice(0, -1))
}
}}
onBlur={() => addTag(input)}
placeholder={tags.length === 0 ? 'Ex.: comício, imprensa…' : 'Adicionar tag…'}
className="min-h-9 flex-1"
list={datalistId}
maxLength={MAX_ACTIVITY_TAG_LENGTH}
/>
) : null}
<datalist id={datalistId}>
{knownTags
.filter((tag) => !tags.includes(tag))
.map((tag) => (
<option key={tag} value={tag} />
))}
</datalist>
</div>
<input type="hidden" name="tagsJson" value={JSON.stringify(tags)} />
<FieldDescription>
Classificação livre do compromisso. Digite e pressione Enter ou vírgula para adicionar.
</FieldDescription>
{error ? <FieldError>{error}</FieldError> : null}
</div>
)
}
const ActivityFormFields = ({
municipalityOptions,
organizationOptions,
@ -184,8 +102,8 @@ const ActivityFormFields = ({
<Field data-invalid={Boolean(errorFor('tagsJson'))}>
<FieldLabel>Tags</FieldLabel>
<TagInput
initialTags={activity?.tags ?? []}
<ActivityTagInput
initialTags={initialValues?.tags ?? activity?.tags ?? []}
knownTags={knownTags}
error={errorFor('tagsJson')}
/>

View file

@ -7,6 +7,7 @@ import { toast } from 'sonner'
import { searchActivityResponsibleOptionsAction } from '@/app/(campaign)/campanha/(app)/atividades/contactSearchActions'
import { createActivityInline } from '@/app/(campaign)/campanha/actions/activity'
import { ActivityDateTimeField } from '@/components/campaign/activity/ActivityDateTimeField'
import { ActivityTagInput } from '@/components/campaign/activity/ActivityTagInput'
import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect'
import { ResponsibleMultiSelect } from '@/components/campaign/shared/ResponsibleMultiSelect'
import { StrictCombobox } from '@/components/campaign/shared/StrictCombobox'
@ -50,6 +51,7 @@ type ActivityInlineCreateProps = {
isNarrow: boolean
agendaState: ActivityAgendaState
municipalityOptions: RelationOption[]
knownTags?: string[]
onClose: () => void
onCreated: () => void
}
@ -57,17 +59,29 @@ type ActivityInlineCreateProps = {
const inlineMunicipalityOptions = (options: RelationOption[]): StrictComboboxOption[] =>
options.map((option) => ({ value: String(option.id), label: option.name }))
const parseInlineResponsibles = (
raw: FormDataEntryValue | null,
): ActivityCreateInput['responsible'] => {
if (typeof raw !== 'string') return undefined
let parsed: unknown
/**
* Bounded JSON-array read for the inline sheet's `responsiblesJson` hidden
* input: same 4 KB cap as the full form's `boundedJsonFormValue`, fail-closed
* on malformed or oversized payloads (the component generates the JSON, so a
* violation means tampering, not user input).
*/
const INLINE_JSON_MAX_BYTES = 4_000
const parseInlineJsonArray = (raw: FormDataEntryValue | null): unknown[] | undefined => {
if (typeof raw !== 'string' || raw.length > INLINE_JSON_MAX_BYTES) return undefined
try {
parsed = JSON.parse(raw)
const parsed: unknown = JSON.parse(raw)
return Array.isArray(parsed) && parsed.length > 0 ? parsed : undefined
} catch {
return undefined
}
if (!Array.isArray(parsed) || parsed.length === 0) return undefined
}
const parseInlineResponsibles = (
raw: FormDataEntryValue | null,
): ActivityCreateInput['responsible'] => {
const parsed = parseInlineJsonArray(raw)
if (!parsed) return undefined
const entries: Array<{
relationTo: 'campaignUser' | 'leadership' | 'stateDeputy'
@ -85,18 +99,21 @@ const ActivityInlineCreateForm = ({
endAt,
agendaState,
municipalityOptions,
knownTags,
onCreated,
}: {
startAt: string
endAt: string
agendaState: ActivityAgendaState
municipalityOptions: RelationOption[]
knownTags?: string[]
onCreated: () => void
}) => {
const [title, setTitle] = useState('')
const [start, setStart] = useState(formatIsoAsBahiaDateTimeInput(startAt))
const [end, setEnd] = useState(formatIsoAsBahiaDateTimeInput(endAt))
const [locality, setLocality] = useState('')
const [tags, setTags] = useState<string[]>([])
const preselectedMunicipalityId =
agendaState.municipality &&
municipalityOptions.some((option) => option.id === agendaState.municipality)
@ -109,11 +126,15 @@ const ActivityInlineCreateForm = ({
const errorFor = (name: string) => fieldErrors[name]?.[0]
// C105 — the href mirrors the LIVE state (times edited in the sheet, typed
// tags), not the slot draft, so "Mais detalhes" carries everything the user
// actually filled.
const moreDetailsHref = buildActivityCreateHref(agendaState, {
startAt,
endAt,
startAt: parseBahiaDateTimeInput(start) ?? undefined,
endAt: end ? (parseBahiaDateTimeInput(end) ?? undefined) : undefined,
municipalityId: municipalityValue ? Number(municipalityValue) : undefined,
title: title.trim() || undefined,
tags: tags.length > 0 ? tags : undefined,
})
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
@ -152,6 +173,9 @@ const ActivityInlineCreateForm = ({
...(endIso ? { endAt: endIso } : {}),
...(locality.trim() ? { locality: locality.trim() } : {}),
...(responsible ? { responsible } : {}),
// C105 — the tags mirror state (every chip mutation fires `onChange`),
// so the payload reads the same source the "Mais detalhes" href uses.
...(tags.length > 0 ? { tags } : {}),
})
if (result.ok) {
onCreated()
@ -245,6 +269,11 @@ const ActivityInlineCreateForm = ({
/>
</Field>
<Field>
<FieldLabel>Tags</FieldLabel>
<ActivityTagInput knownTags={knownTags} onChange={setTags} />
</Field>
<ResponsibleMultiSelect
name="responsiblesJson"
label="Responsáveis"
@ -275,6 +304,7 @@ export const ActivityInlineCreate = ({
isNarrow,
agendaState,
municipalityOptions,
knownTags,
onClose,
onCreated,
}: ActivityInlineCreateProps) => {
@ -286,6 +316,7 @@ export const ActivityInlineCreate = ({
endAt={draft.endAt}
agendaState={agendaState}
municipalityOptions={municipalityOptions}
knownTags={knownTags}
onCreated={onCreated}
/>
)
@ -313,7 +344,18 @@ export const ActivityInlineCreate = ({
>
<PopoverAnchor />
</div>
<PopoverContent align="start" sideOffset={8} className="w-96 p-4">
{/* C105 — the tags field made the sheet taller. The height is capped at
the space Radix computed for the current placement
(`--radix-popper-available-height`, set by its `size` middleware):
the sheet always fits the viewport on its placed side and scrolls
internally instead of clipping fields off-screen (a static cap is
not enough — Radix runs shift before flip, so an overflowing
popover can end up with its top fields out of view). */}
<PopoverContent
align="start"
sideOffset={8}
className="max-h-(--radix-popper-available-height) w-96 overflow-y-auto p-4"
>
{form}
</PopoverContent>
</Popover>

View file

@ -0,0 +1,113 @@
'use client'
import { useId, useState } from 'react'
import { FieldDescription, FieldError } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { MAX_ACTIVITY_TAG_LENGTH, MAX_ACTIVITY_TAGS } from '@/lib/schemas/activity'
/**
* C105 — free-form tag chips shared by the activity form and the agenda's
* inline create sheet (C91). Tags are typed freely, deduped and bounded;
* `knownTags` feed a datalist of tags already used in the campaign
* (autocomplete, never a curated list). The hidden input carries the JSON
* tags under `tagsJson` so the surrounding form's FormData contract stays
* untouched; `onChange` is an optional live mirror for consumers that build
* state from the tags before submit.
*/
export const ActivityTagInput = ({
initialTags = [],
knownTags = [],
error,
onChange,
}: {
initialTags?: string[]
knownTags?: string[]
error?: string
onChange?: (tags: string[]) => void
}) => {
const [tags, setTags] = useState<string[]>(initialTags)
const [input, setInput] = useState('')
const datalistId = useId()
const addTag = (raw: string) => {
const trimmed = raw.trim().slice(0, MAX_ACTIVITY_TAG_LENGTH)
if (!trimmed) return
if (tags.includes(trimmed)) return
if (tags.length >= MAX_ACTIVITY_TAGS) return
const next = [...tags, trimmed]
setTags(next)
setInput('')
onChange?.(next)
}
const removeTag = (tag: string) => {
const next = tags.filter((entry) => entry !== tag)
setTags(next)
onChange?.(next)
}
const removeLastTag = () => {
if (tags.length === 0) return
const next = tags.slice(0, -1)
setTags(next)
onChange?.(next)
}
return (
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-2">
{tags.map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1 rounded-md bg-secondary px-2 py-1 text-xs"
>
{tag}
<button
type="button"
onClick={() => removeTag(tag)}
className="rounded-sm opacity-70 hover:opacity-100"
aria-label={`Remover tag ${tag}`}
>
×
</button>
</span>
))}
{tags.length < MAX_ACTIVITY_TAGS ? (
<Input
value={input}
onChange={(event) => setInput(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ',') {
event.preventDefault()
addTag(input)
} else if (event.key === 'Backspace' && !input) {
event.preventDefault()
removeLastTag()
}
}}
onBlur={() => addTag(input)}
placeholder={tags.length === 0 ? 'Ex.: comício, imprensa…' : 'Adicionar tag…'}
className="min-h-9 flex-1"
list={datalistId}
maxLength={MAX_ACTIVITY_TAG_LENGTH}
aria-label="Adicionar tag"
/>
) : null}
<datalist id={datalistId}>
{knownTags
.filter((tag) => !tags.includes(tag))
.map((tag) => (
<option key={tag} value={tag} />
))}
</datalist>
</div>
<input type="hidden" name="tagsJson" value={JSON.stringify(tags)} />
<FieldDescription>
Classificação livre do compromisso. Digite e pressione Enter ou vírgula para adicionar.
</FieldDescription>
{error ? <FieldError>{error}</FieldError> : null}
</div>
)
}

View file

@ -10,6 +10,7 @@ import { isContactSearchQueryReady, normalizeContactSearchQuery } from '@/lib/co
import {
activityStatuses,
MAX_ACTIVITY_TAG_LENGTH,
MAX_ACTIVITY_TAGS,
type ActivityStatus,
} from '@/lib/schemas/activity'
import {
@ -280,6 +281,7 @@ export type ActivityCreatePrefill = {
endAt?: string
municipalityId?: number
title?: string
tags?: string[]
}
export const buildActivityCreateHref = (
@ -292,6 +294,9 @@ export const buildActivityCreateHref = (
const municipalityId = prefill.municipalityId ?? agendaState.municipality
if (municipalityId) params.set('municipality', String(municipalityId))
if (prefill.title) params.set('title', prefill.title)
// C105 — repeated `tags` params: a free-form tag may contain a comma, so a
// comma-joined single value would corrupt the boundary between tags.
for (const tag of prefill.tags ?? []) params.append('tags', tag)
params.set('returnTo', buildActivityAgendaHref(agendaState))
return `/campanha/atividades/nova?${params.toString()}`
}
@ -359,6 +364,23 @@ const normalizedIsoInstant = (value: string | undefined): string | undefined =>
return Number.isNaN(date.getTime()) ? undefined : date.toISOString()
}
/**
* C105 — bounded prefill tags: repeated `tags` params, trimmed, deduped,
* capped at the same limits the create schema enforces. Anything out of bounds
* is dropped (same drop semantics as the agenda filter's `activityTag`),
* never rejected — a prefill is a convenience, not a contract.
*/
const parseActivityPrefillTags = (value: string | string[] | undefined): string[] | undefined => {
if (value === undefined) return undefined
const tags: string[] = []
for (const entry of Array.isArray(value) ? value : [value]) {
if (tags.length >= MAX_ACTIVITY_TAGS) break
const tag = activityTag(entry)
if (tag && !tags.includes(tag)) tags.push(tag)
}
return tags.length > 0 ? tags : undefined
}
export const parseActivityCreatePrefill = (
params: RawSearchParams,
accessibleMunicipalityIDs: ReadonlySet<number>,
@ -372,12 +394,14 @@ export const parseActivityCreatePrefill = (
const title = normalizedText(firstValue(params.title))
const trimmedTitle = title && title.length <= 160 ? title : undefined
const tags = parseActivityPrefillTags(params.tags)
return {
...(startAt ? { startAt } : {}),
...(endAt ? { endAt } : {}),
...(municipalityId ? { municipalityId } : {}),
...(trimmedTitle ? { title: trimmedTitle } : {}),
...(tags ? { tags } : {}),
}
}

View file

@ -107,7 +107,7 @@ vi.mock('@fullcalendar/react', () => {
return { default: CalendarMock }
})
import { ActivityAgenda } from '@/components/campaign/activity/ActivityAgenda'
import { ActivityAgenda, tagsLabel } from '@/components/campaign/activity/ActivityAgenda'
import { CampaignPageChromeProvider } from '@/components/campaign/shell/CampaignPageChromeContext'
import { stubMatchMedia } from '../helpers/matchMedia'
@ -118,6 +118,22 @@ const renderAgenda = (props: { state?: object } = {}) =>
</CampaignPageChromeProvider>,
)
describe('tagsLabel (C105)', () => {
it('returns null without tags', () => {
expect(tagsLabel([])).toBeNull()
})
it('prefixes tags with # and joins them', () => {
expect(tagsLabel(['Caminhada'])).toBe('#Caminhada')
expect(tagsLabel(['Caminhada', 'Imprensa'])).toBe('#Caminhada #Imprensa')
})
it('caps the row at two tags with a +N overflow', () => {
expect(tagsLabel(['A', 'B', 'C'])).toBe('#A #B +1')
expect(tagsLabel(['A', 'B', 'C', 'D'])).toBe('#A #B +2')
})
})
describe('ActivityAgenda schedule failures', () => {
// C101 — ActivityAgenda reads the viewport signal (useIsMobileMeasured);
// jsdom has no matchMedia, so the harness pins the desktop layout (the

View file

@ -42,12 +42,14 @@ const renderOverlay = ({
draft = DRAFT,
agendaState = {},
isNarrow = false,
knownTags = [],
onCreated = vi.fn(),
onClose = vi.fn(),
}: {
draft?: ActivityInlineCreateDraft | null
agendaState?: ActivityAgendaState
isNarrow?: boolean
knownTags?: string[]
onCreated?: () => void
onClose?: () => void
} = {}) =>
@ -57,6 +59,7 @@ const renderOverlay = ({
isNarrow={isNarrow}
agendaState={agendaState}
municipalityOptions={MUNICIPALITIES}
knownTags={knownTags}
onClose={onClose}
onCreated={onCreated}
/>,
@ -191,6 +194,85 @@ describe('ActivityInlineCreate — overlay de criação inline', () => {
)
})
it('adiciona tags por Enter e as envia ao salvar (C105)', async () => {
const onCreated = vi.fn()
mocks.createInline.mockResolvedValue({ ok: true })
renderOverlay({ agendaState: { municipality: 12 }, onCreated })
const tagInput = screen.getByPlaceholderText('Ex.: comício, imprensa…')
fireEvent.change(tagInput, { target: { value: 'Panfletagem' } })
fireEvent.keyDown(tagInput, { key: 'Enter' })
expect(screen.getByLabelText('Remover tag Panfletagem')).toBeTruthy()
fireEvent.change(screen.getByLabelText('Título *'), {
target: { value: 'Panfletagem no centro' },
})
fireEvent.click(screen.getByRole('button', { name: 'Salvar' }))
await waitFor(() => expect(mocks.createInline).toHaveBeenCalledTimes(1))
expect(mocks.createInline).toHaveBeenCalledWith({
title: 'Panfletagem no centro',
municipality: 12,
startAt: '2026-08-07T16:00:00.000Z',
endAt: '2026-08-07T16:30:00.000Z',
tags: ['Panfletagem'],
})
expect(onCreated).toHaveBeenCalledTimes(1)
})
it('deduplica tags e permite remover um chip antes de salvar (C105)', async () => {
const onCreated = vi.fn()
mocks.createInline.mockResolvedValue({ ok: true })
renderOverlay({ agendaState: { municipality: 12 }, onCreated })
const tagInput = screen.getByPlaceholderText('Ex.: comício, imprensa…')
for (const tag of ['Comício', 'Comício', 'Caminhada']) {
fireEvent.change(tagInput, { target: { value: tag } })
fireEvent.keyDown(tagInput, { key: 'Enter' })
}
expect(screen.getByLabelText('Remover tag Comício')).toBeTruthy()
expect(screen.getByLabelText('Remover tag Caminhada')).toBeTruthy()
fireEvent.click(screen.getByLabelText('Remover tag Comício'))
fireEvent.change(screen.getByLabelText('Título *'), {
target: { value: 'Comício na feira' },
})
fireEvent.click(screen.getByRole('button', { name: 'Salvar' }))
await waitFor(() => expect(mocks.createInline).toHaveBeenCalledTimes(1))
expect(mocks.createInline).toHaveBeenCalledWith({
title: 'Comício na feira',
municipality: 12,
startAt: '2026-08-07T16:00:00.000Z',
endAt: '2026-08-07T16:30:00.000Z',
tags: ['Caminhada'],
})
})
it('oferece as tags já usadas como sugestões e as carrega no href de detalhes (C105)', () => {
renderOverlay({
agendaState: { municipality: 12 },
knownTags: ['Comício', 'Imprensa'],
})
// The overlay renders inside a popover portal, so the datalist lives on
// document.body, not in the container returned by render; the suggestion
// values are `value` attributes, not option text.
const datalist = document.querySelector('datalist')
const suggestionValues = [...(datalist?.querySelectorAll('option') ?? [])].map((option) =>
option.getAttribute('value'),
)
expect(suggestionValues).toContain('Comício')
expect(suggestionValues).toContain('Imprensa')
const tagInput = screen.getByPlaceholderText('Ex.: comício, imprensa…')
fireEvent.change(tagInput, { target: { value: 'Panfletagem' } })
fireEvent.keyDown(tagInput, { key: 'Enter' })
const link = screen.getByRole('link', { name: 'Mais detalhes' })
expect(link.getAttribute('href')).toContain('&tags=Panfletagem')
})
it('não renderiza nada sem um rascunho de slot', () => {
const { container } = renderOverlay({ draft: null })
expect(container.firstChild).toBeNull()

View file

@ -408,6 +408,39 @@ describe('activity create prefill', () => {
})
expect(parseActivityCreatePrefill({ title: 'a'.repeat(200) }, new Set())).toEqual({})
})
it('carries inline tags into the creation URL as repeated params (C105)', () => {
expect(
buildActivityCreateHref(
{ municipality: 12 },
{
startAt: '2026-08-07T13:00:00.000Z',
municipalityId: 12,
title: 'Panfletagem',
tags: ['Panfletagem', 'Caminhada, café'],
},
),
).toBe(
'/campanha/atividades/nova?startAt=2026-08-07T13%3A00%3A00.000Z&municipality=12&title=Panfletagem&tags=Panfletagem&tags=Caminhada%2C+caf%C3%A9&returnTo=%2Fcampanha%2Fagenda%3Fmunicipality%3D12',
)
})
it('round-trips inline tags through the create prefill (C105)', () => {
expect(
parseActivityCreatePrefill(
{ tags: [' Panfletagem ', 'Caminhada, café', 'Panfletagem'] },
new Set(),
),
).toEqual({ tags: ['Panfletagem', 'Caminhada, café'] })
})
it('drops oversized and out-of-bound tags from the prefill (C105)', () => {
const oversized = 'a'.repeat(81)
expect(parseActivityCreatePrefill({ tags: [oversized, ' '] }, new Set())).toEqual({})
expect(parseActivityCreatePrefill({ tags: ['válida', oversized] }, new Set())).toEqual({
tags: ['válida'],
})
})
})
describe('activity slot prefill (C91)', () => {