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:
commit
8c79eddf96
11 changed files with 443 additions and 102 deletions
86
docs/plans/agenda-tags-eventos-impl.md
Normal file
86
docs/plans/agenda-tags-eventos-impl.md
Normal 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)
|
||||||
|
|
@ -87,7 +87,11 @@ export default async function AgendaPage({ searchParams }: AgendaPageProps) {
|
||||||
knownTags={knownTags}
|
knownTags={knownTags}
|
||||||
/>
|
/>
|
||||||
<CampaignListResults>
|
<CampaignListResults>
|
||||||
<ActivityAgenda state={state} municipalityOptions={municipalityOptions} />
|
<ActivityAgenda
|
||||||
|
state={state}
|
||||||
|
municipalityOptions={municipalityOptions}
|
||||||
|
knownTags={knownTags}
|
||||||
|
/>
|
||||||
</CampaignListResults>
|
</CampaignListResults>
|
||||||
</CampaignListPendingBoundary>
|
</CampaignListPendingBoundary>
|
||||||
</CampaignPageShell>
|
</CampaignPageShell>
|
||||||
|
|
|
||||||
|
|
@ -90,7 +90,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.activity-agenda-event-time,
|
.activity-agenda-event-time,
|
||||||
.activity-agenda-event-location {
|
.activity-agenda-event-location,
|
||||||
|
.activity-agenda-event-tags {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
font-size: 0.6875rem;
|
font-size: 0.6875rem;
|
||||||
opacity: 0.82;
|
opacity: 0.82;
|
||||||
|
|
@ -202,7 +203,8 @@
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.activity-agenda-event-location {
|
.activity-agenda-event-location,
|
||||||
|
.activity-agenda-event-tags {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,7 @@ type AgendaEventProps = {
|
||||||
deputyPresent: boolean
|
deputyPresent: boolean
|
||||||
municipalityName: string | null
|
municipalityName: string | null
|
||||||
locality: string | null
|
locality: string | null
|
||||||
|
tags: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventColors: Record<ActivityStatus, { color: string; contrastColor: string }> = {
|
const eventColors: Record<ActivityStatus, { color: string; contrastColor: string }> = {
|
||||||
|
|
@ -95,12 +96,28 @@ const toEventInput = (event: ActivityAgendaEvent): EventInput => ({
|
||||||
deputyPresent: event.deputyPresent,
|
deputyPresent: event.deputyPresent,
|
||||||
municipalityName: event.municipality?.name ?? null,
|
municipalityName: event.municipality?.name ?? null,
|
||||||
locality: event.locality,
|
locality: event.locality,
|
||||||
|
tags: event.tags,
|
||||||
} satisfies AgendaEventProps,
|
} 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 props = event.extendedProps as AgendaEventProps
|
||||||
const location = [props.municipalityName, props.locality].filter(Boolean).join(' · ')
|
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 (
|
return (
|
||||||
<div className="activity-agenda-event-content">
|
<div className="activity-agenda-event-content">
|
||||||
|
|
@ -115,6 +132,7 @@ const renderEventContent = ({ event, timeText }: EventDisplayInfo) => {
|
||||||
</div>
|
</div>
|
||||||
<span className="activity-agenda-event-title">{event.title}</span>
|
<span className="activity-agenda-event-title">{event.title}</span>
|
||||||
{location ? <span className="activity-agenda-event-location">{location}</span> : null}
|
{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>
|
<span className="activity-agenda-event-status">{activityStatusLabels[props.status]}</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -123,9 +141,11 @@ const renderEventContent = ({ event, timeText }: EventDisplayInfo) => {
|
||||||
export const ActivityAgenda = ({
|
export const ActivityAgenda = ({
|
||||||
state,
|
state,
|
||||||
municipalityOptions = [],
|
municipalityOptions = [],
|
||||||
|
knownTags,
|
||||||
}: {
|
}: {
|
||||||
state: ActivityAgendaState
|
state: ActivityAgendaState
|
||||||
municipalityOptions?: RelationOption[]
|
municipalityOptions?: RelationOption[]
|
||||||
|
knownTags?: string[]
|
||||||
}) => {
|
}) => {
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
const calendarRef = useRef<CalendarRef>(null)
|
const calendarRef = useRef<CalendarRef>(null)
|
||||||
|
|
@ -463,6 +483,7 @@ export const ActivityAgenda = ({
|
||||||
isNarrow={isNarrow}
|
isNarrow={isNarrow}
|
||||||
agendaState={state}
|
agendaState={state}
|
||||||
municipalityOptions={municipalityOptions}
|
municipalityOptions={municipalityOptions}
|
||||||
|
knownTags={knownTags}
|
||||||
onClose={() => setCreateDraft(null)}
|
onClose={() => setCreateDraft(null)}
|
||||||
onCreated={() => {
|
onCreated={() => {
|
||||||
setCreateDraft(null)
|
setCreateDraft(null)
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useActionState, useState } from 'react'
|
import { useActionState } from 'react'
|
||||||
|
|
||||||
import { ActivityDemandFields } from '@/components/campaign/activity/ActivityDemandFields'
|
import { ActivityDemandFields } from '@/components/campaign/activity/ActivityDemandFields'
|
||||||
|
import { ActivityTagInput } from '@/components/campaign/activity/ActivityTagInput'
|
||||||
import { ActivityTaskFields } from '@/components/campaign/activity/ActivityTaskFields'
|
import { ActivityTaskFields } from '@/components/campaign/activity/ActivityTaskFields'
|
||||||
import type { ContactComboboxOption } from '@/components/campaign/shared/ContactCombobox'
|
import type { ContactComboboxOption } from '@/components/campaign/shared/ContactCombobox'
|
||||||
import {
|
import {
|
||||||
|
|
@ -18,13 +19,12 @@ import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Checkbox } from '@/components/ui/Checkbox'
|
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 { Input } from '@/components/ui/input'
|
||||||
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
|
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
|
||||||
import { Spinner } from '@/components/ui/Spinner'
|
import { Spinner } from '@/components/ui/Spinner'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { formatIsoAsBahiaDateTimeInput } from '@/lib/campaignTime'
|
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 { ActivityCreatePrefill } from '@/utilities/activityUi'
|
||||||
import type { ActivityFormViewModel } from '@/utilities/activityViewModels'
|
import type { ActivityFormViewModel } from '@/utilities/activityViewModels'
|
||||||
import { fieldError } from '@/utilities/campaignFormFields'
|
import { fieldError } from '@/utilities/campaignFormFields'
|
||||||
|
|
@ -54,88 +54,6 @@ export type ActivityFormFieldsProps = {
|
||||||
knownTags?: string[]
|
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 = ({
|
const ActivityFormFields = ({
|
||||||
municipalityOptions,
|
municipalityOptions,
|
||||||
organizationOptions,
|
organizationOptions,
|
||||||
|
|
@ -184,8 +102,8 @@ const ActivityFormFields = ({
|
||||||
|
|
||||||
<Field data-invalid={Boolean(errorFor('tagsJson'))}>
|
<Field data-invalid={Boolean(errorFor('tagsJson'))}>
|
||||||
<FieldLabel>Tags</FieldLabel>
|
<FieldLabel>Tags</FieldLabel>
|
||||||
<TagInput
|
<ActivityTagInput
|
||||||
initialTags={activity?.tags ?? []}
|
initialTags={initialValues?.tags ?? activity?.tags ?? []}
|
||||||
knownTags={knownTags}
|
knownTags={knownTags}
|
||||||
error={errorFor('tagsJson')}
|
error={errorFor('tagsJson')}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { toast } from 'sonner'
|
||||||
import { searchActivityResponsibleOptionsAction } from '@/app/(campaign)/campanha/(app)/atividades/contactSearchActions'
|
import { searchActivityResponsibleOptionsAction } from '@/app/(campaign)/campanha/(app)/atividades/contactSearchActions'
|
||||||
import { createActivityInline } from '@/app/(campaign)/campanha/actions/activity'
|
import { createActivityInline } from '@/app/(campaign)/campanha/actions/activity'
|
||||||
import { ActivityDateTimeField } from '@/components/campaign/activity/ActivityDateTimeField'
|
import { ActivityDateTimeField } from '@/components/campaign/activity/ActivityDateTimeField'
|
||||||
|
import { ActivityTagInput } from '@/components/campaign/activity/ActivityTagInput'
|
||||||
import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect'
|
import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect'
|
||||||
import { ResponsibleMultiSelect } from '@/components/campaign/shared/ResponsibleMultiSelect'
|
import { ResponsibleMultiSelect } from '@/components/campaign/shared/ResponsibleMultiSelect'
|
||||||
import { StrictCombobox } from '@/components/campaign/shared/StrictCombobox'
|
import { StrictCombobox } from '@/components/campaign/shared/StrictCombobox'
|
||||||
|
|
@ -50,6 +51,7 @@ type ActivityInlineCreateProps = {
|
||||||
isNarrow: boolean
|
isNarrow: boolean
|
||||||
agendaState: ActivityAgendaState
|
agendaState: ActivityAgendaState
|
||||||
municipalityOptions: RelationOption[]
|
municipalityOptions: RelationOption[]
|
||||||
|
knownTags?: string[]
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onCreated: () => void
|
onCreated: () => void
|
||||||
}
|
}
|
||||||
|
|
@ -57,17 +59,29 @@ type ActivityInlineCreateProps = {
|
||||||
const inlineMunicipalityOptions = (options: RelationOption[]): StrictComboboxOption[] =>
|
const inlineMunicipalityOptions = (options: RelationOption[]): StrictComboboxOption[] =>
|
||||||
options.map((option) => ({ value: String(option.id), label: option.name }))
|
options.map((option) => ({ value: String(option.id), label: option.name }))
|
||||||
|
|
||||||
const parseInlineResponsibles = (
|
/**
|
||||||
raw: FormDataEntryValue | null,
|
* Bounded JSON-array read for the inline sheet's `responsiblesJson` hidden
|
||||||
): ActivityCreateInput['responsible'] => {
|
* input: same 4 KB cap as the full form's `boundedJsonFormValue`, fail-closed
|
||||||
if (typeof raw !== 'string') return undefined
|
* on malformed or oversized payloads (the component generates the JSON, so a
|
||||||
let parsed: unknown
|
* 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 {
|
try {
|
||||||
parsed = JSON.parse(raw)
|
const parsed: unknown = JSON.parse(raw)
|
||||||
|
return Array.isArray(parsed) && parsed.length > 0 ? parsed : undefined
|
||||||
} catch {
|
} catch {
|
||||||
return undefined
|
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<{
|
const entries: Array<{
|
||||||
relationTo: 'campaignUser' | 'leadership' | 'stateDeputy'
|
relationTo: 'campaignUser' | 'leadership' | 'stateDeputy'
|
||||||
|
|
@ -85,18 +99,21 @@ const ActivityInlineCreateForm = ({
|
||||||
endAt,
|
endAt,
|
||||||
agendaState,
|
agendaState,
|
||||||
municipalityOptions,
|
municipalityOptions,
|
||||||
|
knownTags,
|
||||||
onCreated,
|
onCreated,
|
||||||
}: {
|
}: {
|
||||||
startAt: string
|
startAt: string
|
||||||
endAt: string
|
endAt: string
|
||||||
agendaState: ActivityAgendaState
|
agendaState: ActivityAgendaState
|
||||||
municipalityOptions: RelationOption[]
|
municipalityOptions: RelationOption[]
|
||||||
|
knownTags?: string[]
|
||||||
onCreated: () => void
|
onCreated: () => void
|
||||||
}) => {
|
}) => {
|
||||||
const [title, setTitle] = useState('')
|
const [title, setTitle] = useState('')
|
||||||
const [start, setStart] = useState(formatIsoAsBahiaDateTimeInput(startAt))
|
const [start, setStart] = useState(formatIsoAsBahiaDateTimeInput(startAt))
|
||||||
const [end, setEnd] = useState(formatIsoAsBahiaDateTimeInput(endAt))
|
const [end, setEnd] = useState(formatIsoAsBahiaDateTimeInput(endAt))
|
||||||
const [locality, setLocality] = useState('')
|
const [locality, setLocality] = useState('')
|
||||||
|
const [tags, setTags] = useState<string[]>([])
|
||||||
const preselectedMunicipalityId =
|
const preselectedMunicipalityId =
|
||||||
agendaState.municipality &&
|
agendaState.municipality &&
|
||||||
municipalityOptions.some((option) => option.id === agendaState.municipality)
|
municipalityOptions.some((option) => option.id === agendaState.municipality)
|
||||||
|
|
@ -109,11 +126,15 @@ const ActivityInlineCreateForm = ({
|
||||||
|
|
||||||
const errorFor = (name: string) => fieldErrors[name]?.[0]
|
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, {
|
const moreDetailsHref = buildActivityCreateHref(agendaState, {
|
||||||
startAt,
|
startAt: parseBahiaDateTimeInput(start) ?? undefined,
|
||||||
endAt,
|
endAt: end ? (parseBahiaDateTimeInput(end) ?? undefined) : undefined,
|
||||||
municipalityId: municipalityValue ? Number(municipalityValue) : undefined,
|
municipalityId: municipalityValue ? Number(municipalityValue) : undefined,
|
||||||
title: title.trim() || undefined,
|
title: title.trim() || undefined,
|
||||||
|
tags: tags.length > 0 ? tags : undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||||
|
|
@ -152,6 +173,9 @@ const ActivityInlineCreateForm = ({
|
||||||
...(endIso ? { endAt: endIso } : {}),
|
...(endIso ? { endAt: endIso } : {}),
|
||||||
...(locality.trim() ? { locality: locality.trim() } : {}),
|
...(locality.trim() ? { locality: locality.trim() } : {}),
|
||||||
...(responsible ? { responsible } : {}),
|
...(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) {
|
if (result.ok) {
|
||||||
onCreated()
|
onCreated()
|
||||||
|
|
@ -245,6 +269,11 @@ const ActivityInlineCreateForm = ({
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel>Tags</FieldLabel>
|
||||||
|
<ActivityTagInput knownTags={knownTags} onChange={setTags} />
|
||||||
|
</Field>
|
||||||
|
|
||||||
<ResponsibleMultiSelect
|
<ResponsibleMultiSelect
|
||||||
name="responsiblesJson"
|
name="responsiblesJson"
|
||||||
label="Responsáveis"
|
label="Responsáveis"
|
||||||
|
|
@ -275,6 +304,7 @@ export const ActivityInlineCreate = ({
|
||||||
isNarrow,
|
isNarrow,
|
||||||
agendaState,
|
agendaState,
|
||||||
municipalityOptions,
|
municipalityOptions,
|
||||||
|
knownTags,
|
||||||
onClose,
|
onClose,
|
||||||
onCreated,
|
onCreated,
|
||||||
}: ActivityInlineCreateProps) => {
|
}: ActivityInlineCreateProps) => {
|
||||||
|
|
@ -286,6 +316,7 @@ export const ActivityInlineCreate = ({
|
||||||
endAt={draft.endAt}
|
endAt={draft.endAt}
|
||||||
agendaState={agendaState}
|
agendaState={agendaState}
|
||||||
municipalityOptions={municipalityOptions}
|
municipalityOptions={municipalityOptions}
|
||||||
|
knownTags={knownTags}
|
||||||
onCreated={onCreated}
|
onCreated={onCreated}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
@ -313,7 +344,18 @@ export const ActivityInlineCreate = ({
|
||||||
>
|
>
|
||||||
<PopoverAnchor />
|
<PopoverAnchor />
|
||||||
</div>
|
</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}
|
{form}
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|
|
||||||
113
src/components/campaign/activity/ActivityTagInput.tsx
Normal file
113
src/components/campaign/activity/ActivityTagInput.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,7 @@ import { isContactSearchQueryReady, normalizeContactSearchQuery } from '@/lib/co
|
||||||
import {
|
import {
|
||||||
activityStatuses,
|
activityStatuses,
|
||||||
MAX_ACTIVITY_TAG_LENGTH,
|
MAX_ACTIVITY_TAG_LENGTH,
|
||||||
|
MAX_ACTIVITY_TAGS,
|
||||||
type ActivityStatus,
|
type ActivityStatus,
|
||||||
} from '@/lib/schemas/activity'
|
} from '@/lib/schemas/activity'
|
||||||
import {
|
import {
|
||||||
|
|
@ -280,6 +281,7 @@ export type ActivityCreatePrefill = {
|
||||||
endAt?: string
|
endAt?: string
|
||||||
municipalityId?: number
|
municipalityId?: number
|
||||||
title?: string
|
title?: string
|
||||||
|
tags?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export const buildActivityCreateHref = (
|
export const buildActivityCreateHref = (
|
||||||
|
|
@ -292,6 +294,9 @@ export const buildActivityCreateHref = (
|
||||||
const municipalityId = prefill.municipalityId ?? agendaState.municipality
|
const municipalityId = prefill.municipalityId ?? agendaState.municipality
|
||||||
if (municipalityId) params.set('municipality', String(municipalityId))
|
if (municipalityId) params.set('municipality', String(municipalityId))
|
||||||
if (prefill.title) params.set('title', prefill.title)
|
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))
|
params.set('returnTo', buildActivityAgendaHref(agendaState))
|
||||||
return `/campanha/atividades/nova?${params.toString()}`
|
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()
|
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 = (
|
export const parseActivityCreatePrefill = (
|
||||||
params: RawSearchParams,
|
params: RawSearchParams,
|
||||||
accessibleMunicipalityIDs: ReadonlySet<number>,
|
accessibleMunicipalityIDs: ReadonlySet<number>,
|
||||||
|
|
@ -372,12 +394,14 @@ export const parseActivityCreatePrefill = (
|
||||||
|
|
||||||
const title = normalizedText(firstValue(params.title))
|
const title = normalizedText(firstValue(params.title))
|
||||||
const trimmedTitle = title && title.length <= 160 ? title : undefined
|
const trimmedTitle = title && title.length <= 160 ? title : undefined
|
||||||
|
const tags = parseActivityPrefillTags(params.tags)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...(startAt ? { startAt } : {}),
|
...(startAt ? { startAt } : {}),
|
||||||
...(endAt ? { endAt } : {}),
|
...(endAt ? { endAt } : {}),
|
||||||
...(municipalityId ? { municipalityId } : {}),
|
...(municipalityId ? { municipalityId } : {}),
|
||||||
...(trimmedTitle ? { title: trimmedTitle } : {}),
|
...(trimmedTitle ? { title: trimmedTitle } : {}),
|
||||||
|
...(tags ? { tags } : {}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -107,7 +107,7 @@ vi.mock('@fullcalendar/react', () => {
|
||||||
return { default: CalendarMock }
|
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 { CampaignPageChromeProvider } from '@/components/campaign/shell/CampaignPageChromeContext'
|
||||||
import { stubMatchMedia } from '../helpers/matchMedia'
|
import { stubMatchMedia } from '../helpers/matchMedia'
|
||||||
|
|
||||||
|
|
@ -118,6 +118,22 @@ const renderAgenda = (props: { state?: object } = {}) =>
|
||||||
</CampaignPageChromeProvider>,
|
</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', () => {
|
describe('ActivityAgenda schedule failures', () => {
|
||||||
// C101 — ActivityAgenda reads the viewport signal (useIsMobileMeasured);
|
// C101 — ActivityAgenda reads the viewport signal (useIsMobileMeasured);
|
||||||
// jsdom has no matchMedia, so the harness pins the desktop layout (the
|
// jsdom has no matchMedia, so the harness pins the desktop layout (the
|
||||||
|
|
|
||||||
|
|
@ -42,12 +42,14 @@ const renderOverlay = ({
|
||||||
draft = DRAFT,
|
draft = DRAFT,
|
||||||
agendaState = {},
|
agendaState = {},
|
||||||
isNarrow = false,
|
isNarrow = false,
|
||||||
|
knownTags = [],
|
||||||
onCreated = vi.fn(),
|
onCreated = vi.fn(),
|
||||||
onClose = vi.fn(),
|
onClose = vi.fn(),
|
||||||
}: {
|
}: {
|
||||||
draft?: ActivityInlineCreateDraft | null
|
draft?: ActivityInlineCreateDraft | null
|
||||||
agendaState?: ActivityAgendaState
|
agendaState?: ActivityAgendaState
|
||||||
isNarrow?: boolean
|
isNarrow?: boolean
|
||||||
|
knownTags?: string[]
|
||||||
onCreated?: () => void
|
onCreated?: () => void
|
||||||
onClose?: () => void
|
onClose?: () => void
|
||||||
} = {}) =>
|
} = {}) =>
|
||||||
|
|
@ -57,6 +59,7 @@ const renderOverlay = ({
|
||||||
isNarrow={isNarrow}
|
isNarrow={isNarrow}
|
||||||
agendaState={agendaState}
|
agendaState={agendaState}
|
||||||
municipalityOptions={MUNICIPALITIES}
|
municipalityOptions={MUNICIPALITIES}
|
||||||
|
knownTags={knownTags}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
onCreated={onCreated}
|
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', () => {
|
it('não renderiza nada sem um rascunho de slot', () => {
|
||||||
const { container } = renderOverlay({ draft: null })
|
const { container } = renderOverlay({ draft: null })
|
||||||
expect(container.firstChild).toBeNull()
|
expect(container.firstChild).toBeNull()
|
||||||
|
|
|
||||||
|
|
@ -408,6 +408,39 @@ describe('activity create prefill', () => {
|
||||||
})
|
})
|
||||||
expect(parseActivityCreatePrefill({ title: 'a'.repeat(200) }, new Set())).toEqual({})
|
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)', () => {
|
describe('activity slot prefill (C91)', () => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue