fix(campanha): B147 simplify — sinal empty body + cold select
Allow empty motivo on sinal through collection beforeValidate; distinguish warm-typeless vs cold in the v2 signal select; reuse list signal form action. Co-authored-by: Francisco Solla <fsolla@users.noreply.github.com>
This commit is contained in:
parent
c66ffd598c
commit
6b8aa8b6cf
8 changed files with 37 additions and 40 deletions
|
|
@ -65,11 +65,11 @@ flowchart LR
|
|||
|
||||
- **`MunicipalityV2Page`** (`src/app/(campaign)/campanha/(app)/municipio/[slug]/v2/page.tsx`): RSC; `requireCampaignPageActor({ gate: 'noLeader' })`; status strip; empty slots for conta/rede/agora (children).
|
||||
- **`loadMunicipalityV2StatusData`** (`src/utilities/municipality/municipalityV2StatusData.ts`): server-only composer.
|
||||
- **`buildMunicipalityV2StatusAggregate`** (`src/lib/municipalityV2Status.ts`): pure aggregate text + cold sentinel helpers (unit-tested).
|
||||
- **`buildMunicipalityV2StatusAggregate`** (`src/utilities/municipality/municipalityV2StatusView.ts`): pure aggregate text + cold sentinel helpers (unit-tested). Client-safe utilities (depends on `municipalitySignal`).
|
||||
- **`MunicipalityV2StatusStrip`** + **`MunicipalityV2StatusReasonDialog`** (`src/components/campaign/municipality/`): client strip + shared Dialog (motivo opcional).
|
||||
- **Schema/action/list level:** `municipalityEngagementLevelSchema` note → optional; `reversalSignals` optional/default `''`; list control drops reversal field; int tests updated.
|
||||
- **Schema/action/list level:** `municipalityEngagementLevelSchema` note → optional; drop `reversalSignals` from write; list control drops reversal field; int tests updated.
|
||||
- **Chrome:** path rule `/campanha/municipio/[^/]+/v2`.
|
||||
- **Form action:** reuse `createMunicipalityListSignalFormAction` (or thin v2 wrapper colocated under the new route).
|
||||
- **Form action:** thin alias over `createMunicipalityListSignalFormAction`.
|
||||
- **Migration:** nenhuma.
|
||||
- **Access / Consent:** existing municipality access; no Consent; leader blocked by page gate.
|
||||
- **UI:** Impeccable C — shape → craft → critique → polish; tokens `data-theme='campaign'`; `CampaignHoverTooltip` + `campaignConceptHref` / `campaignConceptOneLiner`.
|
||||
|
|
|
|||
|
|
@ -41,7 +41,8 @@ export const E2E_AFFECTED_MANIFEST = [
|
|||
'campaignSavedFilters',
|
||||
'campaignColumnPicker',
|
||||
],
|
||||
}, {
|
||||
},
|
||||
{
|
||||
prefixes: [
|
||||
`${CAMPAIGN_APP}/atividades`,
|
||||
'src/components/campaign/activity',
|
||||
|
|
|
|||
|
|
@ -1,35 +1,9 @@
|
|||
'use server'
|
||||
|
||||
import { createMunicipalityUpdate } from '@/app/(campaign)/campanha/actions/municipalityUpdate'
|
||||
import {
|
||||
optionalFormText,
|
||||
optionalMunicipalitySlugFromForm,
|
||||
requiredRelationshipFormValue,
|
||||
} from '@/lib/formData'
|
||||
import { parseMunicipalitySignalType } from '@/lib/schemas/municipalityUpdate'
|
||||
import {
|
||||
runCampaignFormAction,
|
||||
type CampaignFormActionState,
|
||||
} from '@/utilities/campaignFormActionError'
|
||||
import { revalidateMunicipalityListPaths } from '@/utilities/municipality/municipalityRevalidation'
|
||||
|
||||
const SIGNAL_SAVED_MESSAGE = 'Sinal registrado.'
|
||||
import { createMunicipalityListSignalFormAction } from '@/app/(campaign)/campanha/(app)/municipios/municipalityStaffFormActions'
|
||||
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
|
||||
|
||||
/** Thin alias — same write path as the list signal control (B147). */
|
||||
export const createMunicipalityV2SignalFormAction = async (
|
||||
formData: FormData,
|
||||
): Promise<CampaignFormActionState> =>
|
||||
runCampaignFormAction({
|
||||
execute: async () => {
|
||||
const municipality = requiredRelationshipFormValue(formData, 'municipalityId')
|
||||
|
||||
await createMunicipalityUpdate({
|
||||
municipality,
|
||||
kind: 'sinal',
|
||||
body: optionalFormText(formData, 'body'),
|
||||
signalType: parseMunicipalitySignalType(optionalFormText(formData, 'signalType')),
|
||||
})
|
||||
revalidateMunicipalityListPaths({ slug: optionalMunicipalitySlugFromForm(formData) })
|
||||
return { message: SIGNAL_SAVED_MESSAGE }
|
||||
},
|
||||
genericMessage: 'Não foi possível registrar o sinal. Verifique seu acesso e tente novamente.',
|
||||
})
|
||||
): Promise<CampaignFormActionState> => createMunicipalityListSignalFormAction({}, formData)
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ const validateMunicipalityUpdateKind: CollectionBeforeValidateHook = ({
|
|||
if (!nonEmptyText(nextData.failed)) throw new APIError('Informe o que não funcionou.', 400)
|
||||
if (!nonEmptyText(nextData.needs)) throw new APIError('Informe o que você precisa.', 400)
|
||||
} else if (kind === 'sinal') {
|
||||
if (!nonEmptyText(nextData.body)) throw new APIError('Informe o texto da atualização.', 400)
|
||||
// Motivo/body is optional (B147/B134); type is the required discriminator.
|
||||
if (typeof nextData.signalType !== 'string') {
|
||||
throw new APIError('Informe o tipo do sinal.', 400)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useId, useState, type ReactNode } from 'react'
|
||||
import { useId, useState } from 'react'
|
||||
|
||||
import { Alert, AlertDescription } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
|
@ -37,7 +37,6 @@ export type MunicipalityV2StatusReasonDialogProps = {
|
|||
override?: boolean
|
||||
onOverrideChange?: (value: boolean) => void
|
||||
onConfirm: (reason: string) => void
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -393,7 +393,7 @@ export const MunicipalityV2StatusStrip = ({
|
|||
return
|
||||
}
|
||||
const next = parseMunicipalitySignalType(raw)
|
||||
if (!next || next === signalType) {
|
||||
if (!next || next === signalSelect.value) {
|
||||
event.target.value = signalSelect.value
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,7 +58,8 @@ export type MunicipalityV2SignalSelectState = {
|
|||
|
||||
/**
|
||||
* Product assumption (intention open Q): cold → sentinel “Sem sinal / frio”
|
||||
* in the strip; age lives in aggregate/tooltip.
|
||||
* in the strip; age lives in aggregate/tooltip. Warm but typeless → “Sem tipo”
|
||||
* without claiming frio (frescor can still be warm from notes/pledges).
|
||||
*/
|
||||
export const resolveMunicipalityV2SignalSelectState = (
|
||||
input: {
|
||||
|
|
@ -70,7 +71,7 @@ export const resolveMunicipalityV2SignalSelectState = (
|
|||
const ageInDays = municipalitySignalAgeInDays(input.lastSignalAt, now)
|
||||
const isCold = isMunicipalitySignalCold(ageInDays)
|
||||
|
||||
if (isCold || !input.signalType) {
|
||||
if (isCold) {
|
||||
const daysLabel =
|
||||
ageInDays === null ? `frio (≥${MUNICIPALITY_COLD_SIGNAL_DAYS} d)` : `frio (${ageInDays} d)`
|
||||
return {
|
||||
|
|
@ -81,6 +82,15 @@ export const resolveMunicipalityV2SignalSelectState = (
|
|||
}
|
||||
}
|
||||
|
||||
if (!input.signalType) {
|
||||
return {
|
||||
value: MUNICIPALITY_V2_SIGNAL_COLD_VALUE,
|
||||
label: 'Sem tipo de sinal',
|
||||
ageInDays,
|
||||
isCold: false,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
value: input.signalType,
|
||||
label: municipalitySignalTypeLabels[input.signalType],
|
||||
|
|
|
|||
|
|
@ -17,6 +17,19 @@ describe('municipalityV2StatusView', () => {
|
|||
expect(state.label).toContain('Sem sinal')
|
||||
})
|
||||
|
||||
it('does not claim frio when frescor is warm but typeless', () => {
|
||||
const state = resolveMunicipalityV2SignalSelectState(
|
||||
{
|
||||
signalType: null,
|
||||
lastSignalAt: '2026-08-01T12:00:00.000Z',
|
||||
},
|
||||
new Date('2026-08-02T12:00:00.000Z'),
|
||||
)
|
||||
expect(state.value).toBe(MUNICIPALITY_V2_SIGNAL_COLD_VALUE)
|
||||
expect(state.isCold).toBe(false)
|
||||
expect(state.label).toBe('Sem tipo de sinal')
|
||||
})
|
||||
|
||||
it('keeps the typed signal when still warm', () => {
|
||||
const state = resolveMunicipalityV2SignalSelectState(
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue