diff --git a/docs/plans/agenda-tags-eventos-impl.md b/docs/plans/agenda-tags-eventos-impl.md new file mode 100644 index 00000000..0c93b9c5 --- /dev/null +++ b/docs/plans/agenda-tags-eventos-impl.md @@ -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) diff --git a/src/app/(campaign)/campanha/(app)/agenda/page.tsx b/src/app/(campaign)/campanha/(app)/agenda/page.tsx index b0fb58e2..17a79263 100644 --- a/src/app/(campaign)/campanha/(app)/agenda/page.tsx +++ b/src/app/(campaign)/campanha/(app)/agenda/page.tsx @@ -87,7 +87,11 @@ export default async function AgendaPage({ searchParams }: AgendaPageProps) { knownTags={knownTags} /> - + diff --git a/src/components/campaign/activity/ActivityAgenda.css b/src/components/campaign/activity/ActivityAgenda.css index 774bdf85..e7acaf29 100644 --- a/src/components/campaign/activity/ActivityAgenda.css +++ b/src/components/campaign/activity/ActivityAgenda.css @@ -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; } } diff --git a/src/components/campaign/activity/ActivityAgenda.tsx b/src/components/campaign/activity/ActivityAgenda.tsx index d23b81a3..da15d350 100644 --- a/src/components/campaign/activity/ActivityAgenda.tsx +++ b/src/components/campaign/activity/ActivityAgenda.tsx @@ -57,6 +57,7 @@ type AgendaEventProps = { deputyPresent: boolean municipalityName: string | null locality: string | null + tags: string[] } const eventColors: Record = { @@ -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 (
@@ -115,6 +132,7 @@ const renderEventContent = ({ event, timeText }: EventDisplayInfo) => {
{event.title} {location ? {location} : null} + {tags ? {tags} : null} {activityStatusLabels[props.status]} ) @@ -123,9 +141,11 @@ const renderEventContent = ({ event, timeText }: EventDisplayInfo) => { export const ActivityAgenda = ({ state, municipalityOptions = [], + knownTags, }: { state: ActivityAgendaState municipalityOptions?: RelationOption[] + knownTags?: string[] }) => { const containerRef = useRef(null) const calendarRef = useRef(null) @@ -463,6 +483,7 @@ export const ActivityAgenda = ({ isNarrow={isNarrow} agendaState={state} municipalityOptions={municipalityOptions} + knownTags={knownTags} onClose={() => setCreateDraft(null)} onCreated={() => { setCreateDraft(null) diff --git a/src/components/campaign/activity/ActivityForm.tsx b/src/components/campaign/activity/ActivityForm.tsx index 23778147..237d92ef 100644 --- a/src/components/campaign/activity/ActivityForm.tsx +++ b/src/components/campaign/activity/ActivityForm.tsx @@ -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(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 ( -
-
- {tags.map((tag) => ( - - {tag} - - - ))} - {tags.length < MAX_ACTIVITY_TAGS ? ( - 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} - - {knownTags - .filter((tag) => !tags.includes(tag)) - .map((tag) => ( - -
- - - Classificação livre do compromisso. Digite e pressione Enter ou vírgula para adicionar. - - {error ? {error} : null} -
- ) -} - const ActivityFormFields = ({ municipalityOptions, organizationOptions, @@ -184,8 +102,8 @@ const ActivityFormFields = ({ Tags - diff --git a/src/components/campaign/activity/ActivityInlineCreate.tsx b/src/components/campaign/activity/ActivityInlineCreate.tsx index 48e701a9..5bbfba8f 100644 --- a/src/components/campaign/activity/ActivityInlineCreate.tsx +++ b/src/components/campaign/activity/ActivityInlineCreate.tsx @@ -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([]) 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) => { @@ -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 = ({ /> + + Tags + + + { @@ -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 = ({ > - + {/* 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). */} + {form} diff --git a/src/components/campaign/activity/ActivityTagInput.tsx b/src/components/campaign/activity/ActivityTagInput.tsx new file mode 100644 index 00000000..9ec7a373 --- /dev/null +++ b/src/components/campaign/activity/ActivityTagInput.tsx @@ -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(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 ( +
+
+ {tags.map((tag) => ( + + {tag} + + + ))} + {tags.length < MAX_ACTIVITY_TAGS ? ( + 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} + + {knownTags + .filter((tag) => !tags.includes(tag)) + .map((tag) => ( + +
+ + + Classificação livre do compromisso. Digite e pressione Enter ou vírgula para adicionar. + + {error ? {error} : null} +
+ ) +} diff --git a/src/utilities/activityUi.ts b/src/utilities/activityUi.ts index 404a5785..5053fe36 100644 --- a/src/utilities/activityUi.ts +++ b/src/utilities/activityUi.ts @@ -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, @@ -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 } : {}), } } diff --git a/tests/unit/activityAgendaInteractions.unit.spec.tsx b/tests/unit/activityAgendaInteractions.unit.spec.tsx index d9b2f10b..9208bd2a 100644 --- a/tests/unit/activityAgendaInteractions.unit.spec.tsx +++ b/tests/unit/activityAgendaInteractions.unit.spec.tsx @@ -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 } = {}) => , ) +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 diff --git a/tests/unit/activityInlineCreate.unit.spec.tsx b/tests/unit/activityInlineCreate.unit.spec.tsx index 7acc12c0..801cceec 100644 --- a/tests/unit/activityInlineCreate.unit.spec.tsx +++ b/tests/unit/activityInlineCreate.unit.spec.tsx @@ -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() diff --git a/tests/unit/activityUi.unit.spec.ts b/tests/unit/activityUi.unit.spec.ts index 3c770117..1b73f204 100644 --- a/tests/unit/activityUi.unit.spec.ts +++ b/tests/unit/activityUi.unit.spec.ts @@ -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)', () => {