mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
feat(C105): tags no sheet da agenda + rótulo no evento + prefill no "Mais detalhes"
O filtro por tag na omnibox já existia (C94); o C105 completa a superfície da agenda: campo de tags no overlay de criação inline (chips, sugestões das tags já usadas), rótulo leve #tag no evento do calendário (oculto no mês/mobile) e tags via prefill do "Mais detalhes" (params repetidos, parse fail-closed). - ActivityTagInput extraído do ActivityForm (compartilhado, aria-label novo) - "Mais detalhes" espelha estado vivo (horários editados + tags) - popover do sheet com max-h --radix-popper-available-height (shift-before-flip do Radix clipava os campos do topo em viewports baixos) - submit lê tags do estado espelho (fonte única), 4 KB cap p/ responsáveisJson - limites mantidos 20/80 (C14), sem migration/access/Consent Impl: docs/plans/agenda-tags-eventos-impl.md (aprovado no gate humano)
This commit is contained in:
parent
2c4d2cfde9
commit
cd41b44de4
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}
|
||||
/>
|
||||
<CampaignListResults>
|
||||
<ActivityAgenda state={state} municipalityOptions={municipalityOptions} />
|
||||
<ActivityAgenda
|
||||
state={state}
|
||||
municipalityOptions={municipalityOptions}
|
||||
knownTags={knownTags}
|
||||
/>
|
||||
</CampaignListResults>
|
||||
</CampaignListPendingBoundary>
|
||||
</CampaignPageShell>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
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 {
|
||||
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 } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)', () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue