mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 23:53:05 +00:00
B63: wizard registrar sinal (grid de tipos + passo de texto).
Two-step UX-1 flow on the B59 chassis with URL contract ?signalType= and optional ?entryAction= for embedded skip; reuses createMunicipalityListSignalFormAction.
This commit is contained in:
parent
fbce90069c
commit
09fa21d4b3
13 changed files with 562 additions and 6 deletions
|
|
@ -8,6 +8,7 @@ alwaysApply: false
|
|||
## Status
|
||||
|
||||
- **B70 entregue em código (2026-07-30):** wizard atualizar liderança (A4 standalone) — `/campanha/acoes/atualizar-lideranca?municipio=` com grid 2/3/4 (`WizardLeadershipStep`), form curto de 6 campos (`WizardLeadershipForm`), skip via `trailingAction` quando `entryAction !== 'update-leadership'`, Continuar após Salvar dirty → Início + toast; `loadWizardLeadershipTiles`; writes `updateLeadershipWizard`/`createLeadershipWizard` (Contact+Leadership em transação); `WIZARD_ENTRY_ACTION_QUERY_KEY` em `campaignActionRoutes.ts`; `truncateNameAtWordBoundary`. Sem migration. Plano: [`wizard-atualizar-lideranca.md`](../../../docs/plans/wizard-atualizar-lideranca.md).
|
||||
- **B63 entregue em código (2026-07-30):** wizard registrar sinal (UX-1) — duas etapas no chassis B59: `WizardSignalTypeStep` (grid 2×3 de tiles transparentes + borda, ícone Lucide, Drawer bottom com `infoContent` provisório, `contentAlign="end"` no mobile) e `WizardSignalBodyStep` (textarea + Salvar explícito via `createMunicipalityListSignalFormAction` → `createMunicipalityUpdate` `kind: 'sinal'`). Contrato URL: `?municipio=` (B60) → `?signalType=` (tipo) → body; `?entry=` opcional — skip "Pular registro de sinal" visível só quando embutido (`entry` presente e ≠ `register-signal`), href skip → `/campanha` na v1. Helpers: `wizardSignalHref`, `resolveWizardSignalTypeParam`, `parseWizardEntryActionParam` em `campaignActionRoutes.ts`; `municipalitySignalTypeMeta.ts`; `wizardSignalUi.ts`. B63+ alinha `flowTitle`/`skip` ao chrome B75. Sem migration/Consent. Unit `campaignActionRoutes` + `wizardSignalUi`; e2e standalone + info não avança + skip embutido. Plano: [`wizard-registro-sinal.md`](../../../docs/plans/wizard-registro-sinal.md). Desbloqueia prefill de **B64** (soft).
|
||||
- **B75 entregue em código (2026-07-30):** header mobile do wizard — em `/campanha/acoes/*` no mobile (`< md`), o Mandate Red deixa de ser sidebar+marca e vira chrome de fluxo: título do catálogo B45 (`wizardFlowTitleForSlug`), subtítulo do município após B60, 1ª etapa só X (`aria-label="Sair da ação"`), 2ª+ Voltar+X; `skip` substitui X quando encadeado (B63+). `CampaignWizardChromeProvider` + `CampaignMobileTopBar` no layout `(app)`; `CampaignWizardShell` publica chrome via `useLayoutEffect` (handoff entre passos sem flash app-mode); desktop mantém caption muted no `<main>` (`md:block`). Helpers: `campaignWizardActionIdForSlug`, `wizardFlowTitleForActionId`, `toCampaignWizardChromeState`. `data-wizard-route` em `acoes/layout.tsx`. Unit `campaignMobileTopBar`, `campaignWizardShell`, `campaignWizardCopy`; e2e `campaignWizardChrome.e2e.spec.ts` (viewport 390). Débito adiado: skip no desktop; split read/write context do provider (gatilho: re-render medido no inset). Plano: [`header-mobile-wizard-campanha.md`](../../../docs/plans/header-mobile-wizard-campanha.md). Desbloqueia polish mobile de B63/B64/B70/B77/B78 sobre chrome único.
|
||||
- **B79–B90 registrados (2026-07-30):** pós-**B73** (remover bottom nav), chrome mobile fora do Início = bottom drawer **não-modal** (ações contextuais + busca global) — chassis **B79** + catálogos por vertical **B80–B90** (Municípios, Territórios, Lideranças, Dobradinhas, Atividades, Demandas, Apoiadores, Assessores, Organizações, Contatos leader, Conceitos+Perfil). **Quadro fora** do inventário. Granularidade = uma vertical (lista+detalhe). Lote inteiro **não cortável**. Planos em `docs/plans/chassis-bottom-drawer-acoes-rapidas.md` e `acoes-rapidas-*.md`.
|
||||
- **B77 entregue em código (2026-07-30):** wizard ajuste de votos numa tela — `WizardExpectedVotesStep` substitui o ritual `?cenario=` de B61/B76 por editor único (strip + 3 inputs compact + atalhos no cenário focado); coerência `pessimistic ≤ central ≤ optimistic` só no confirm (`getWizardVoteViolation` + `getWizardVoteViolationHighlights`); `?cenario=` legado → redirect canônico; órfãos `wizardVoteStepTitle`/`mergeWizardVoteEstimate`/etc. removidos. Unit `wizardExpectedVotesStep`/`wizardVoteEstimate`; e2e B61 atualizado. Sem migration. Plano: [`simplificar-ajuste-votos-wizard.md`](../../../docs/plans/simplificar-ajuste-votos-wizard.md). Desbloqueia wiring A1 com menos atrito antes de **B63**/**B64**.
|
||||
|
|
|
|||
|
|
@ -1,12 +1,30 @@
|
|||
# Wizard registrar sinal — escolher tipo + detalhar texto
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-07-29
|
||||
Status: entregue (2026-07-30)
|
||||
Atualizado em: 2026-07-30
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item B63 — UX-1 wizards)
|
||||
Impeccable: C — UI nova (grid de tipos + drawer de info + passo de texto)
|
||||
Appetite: ~1–1,25 dia eng; 2 etapas no B59; write via action `createMunicipalityUpdate` existente
|
||||
Responsável: —
|
||||
|
||||
## Contrato de URL (as-built)
|
||||
|
||||
```text
|
||||
/campanha/acoes/registrar-sinal
|
||||
→ B60 WizardMunicipalitySearchStep
|
||||
|
||||
/campanha/acoes/registrar-sinal?municipio=<slug>
|
||||
→ WizardSignalTypeStep (grid)
|
||||
|
||||
/campanha/acoes/registrar-sinal?municipio=<slug>&signalType=<enum>
|
||||
→ WizardSignalBodyStep (textarea + Salvar)
|
||||
|
||||
Query opcional (propagar entre passos):
|
||||
?entryAction=register-signal|update-votes|…
|
||||
```
|
||||
|
||||
Helpers em `src/lib/campaignActionRoutes.ts`: `WIZARD_SIGNAL_TYPE_QUERY_KEY`, `WIZARD_ENTRY_ACTION_QUERY_KEY`, `parseWizardEntryActionParam`, `resolveWizardSignalTypeParam`, `wizardSignalHref`. Skip: `shouldShowWizardSignalSkip` em `wizardSignalUi.ts` — oculto quando `entryAction` ausente ou `register-signal`; visível caso contrário (href → `/campanha` na v1).
|
||||
|
||||
## Design (Impeccable)
|
||||
|
||||
Âncoras: `PRODUCT.md` / `DESIGN.md` · `Drawer` shadcn · tema `campaign` · labels de **B62**.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Roadmap — Teqo
|
||||
|
||||
Atualizado em: 2026-07-30 — **B79–B90** bottom drawer de ações rápidas + busca (chassis + catálogos por vertical; sem Quadro; lote não cortável); **B78** pending nav wizard; ~~**B77**~~ ✓ simplificar ajuste votos; ~~**B70**~~ ✓ wizard A4 liderança; **B74** gap strip v2; ~~**B75**~~ ✓ header mobile wizard; **B73** remover bottom nav; ~~B76/B66/B71/B69/B62/B56/B67/B68/B59/B65/B60/B48–B52/B72~~ ✓; B53–B55; wizards B63/B64/B78.
|
||||
Atualizado em: 2026-07-30 — **B79–B90** bottom drawer de ações rápidas + busca (chassis + catálogos por vertical; sem Quadro; lote não cortável); **B78** pending nav wizard; ~~**B77**~~ ✓ simplificar ajuste votos; ~~**B70**~~ ✓ wizard A4 liderança; **B74** gap strip v2; ~~**B75**~~ ✓ header mobile wizard; **B73** remover bottom nav; ~~**B63**~~ ✓ wizard registrar sinal; ~~B76/B66/B71/B69/B62/B56/B67/B68/B59/B65/B60/B48–B52/B72~~ ✓; B53–B55; wizards ~~B63~~ ✓/B64/B78.
|
||||
Registro canônico dos **próximos** planos e débitos. Histórico de entregas: resumo abaixo + planos em [`docs/plans/`](plans/) + notebook [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc).
|
||||
|
||||
## Âncoras do calendário eleitoral 2026 (Res. TSE 23.760/2026)
|
||||
|
|
@ -83,7 +83,7 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re
|
|||
|
||||
### Demais itens abertos
|
||||
|
||||
- **UX-1 fluxos ação-primeiro (prioridade pós-sessão 29/07)** — evidência: [snapshot](plans/sessao-observada-coordenador-2026-07-29-snapshot.md) · O9–O11 em CUSTOMER.md · **rascunho:** [fluxos-acao-primeiro-inicio.md](plans/fluxos-acao-primeiro-inicio.md). Soft deadline: **03/08**. **Chassis Início:** ~~**B43**~~ ✓ → ~~**B44**~~ ✓ → ~~**B45**~~ ✓ → ~~**B46**~~ ✓ → ~~**B47**~~ ✓ → ~~**B58**~~ ✓ → ~~**B67**~~ ✓ (pan strip) → ~~**B65**~~ ✓ (âncora inferior mobile) → ~~**B66**~~ ✓ (focus limpa tela + animação) → ~~**B48**~~ ✓ → ~~**B49**~~ ✓ → ~~**B68**~~ ✓ → ~~**B71**~~ ✓ (polish resultados busca) · ~~**B72**~~ ✓ (gap strip) → **B74** (gap strip v2) → **B73** (remover bottom nav mobile) → **B79** (bottom drawer ações+busca fora do Início) → **B80–B90** (catálogos por vertical; Quadro fora) → ~~**B50**~~ ✓ → ~~**B51**~~ ✓ → ~~**B52**~~ ✓ → **B53–B55** · ~~**B56**~~ ✓ · **B57** futuro. **Wizards:** ~~**B59**~~ ✓ → ~~**B75**~~ ✓ (header mobile wizard) → **B78** (pending navegação shell) → ~~**B60**~~ ✓/~~**B61**~~ ✓/~~**B76**~~ ✓/~~**B77**~~ ✓ → **B64** · ~~**B62**~~ ✓ → **B63** · ~~**B69**~~ ✓ → ~~**B70**~~ ✓ (A4 liderança).
|
||||
- **UX-1 fluxos ação-primeiro (prioridade pós-sessão 29/07)** — evidência: [snapshot](plans/sessao-observada-coordenador-2026-07-29-snapshot.md) · O9–O11 em CUSTOMER.md · **rascunho:** [fluxos-acao-primeiro-inicio.md](plans/fluxos-acao-primeiro-inicio.md). Soft deadline: **03/08**. **Chassis Início:** ~~**B43**~~ ✓ → ~~**B44**~~ ✓ → ~~**B45**~~ ✓ → ~~**B46**~~ ✓ → ~~**B47**~~ ✓ → ~~**B58**~~ ✓ → ~~**B67**~~ ✓ (pan strip) → ~~**B65**~~ ✓ (âncora inferior mobile) → ~~**B66**~~ ✓ (focus limpa tela + animação) → ~~**B48**~~ ✓ → ~~**B49**~~ ✓ → ~~**B68**~~ ✓ → ~~**B71**~~ ✓ (polish resultados busca) · ~~**B72**~~ ✓ (gap strip) → **B74** (gap strip v2) → **B73** (remover bottom nav mobile) → **B79** (bottom drawer ações+busca fora do Início) → **B80–B90** (catálogos por vertical; Quadro fora) → ~~**B50**~~ ✓ → ~~**B51**~~ ✓ → ~~**B52**~~ ✓ → **B53–B55** · ~~**B56**~~ ✓ · **B57** futuro. **Wizards:** ~~**B59**~~ ✓ → ~~**B75**~~ ✓ (header mobile wizard) → **B78** (pending navegação shell) → ~~**B60**~~ ✓/~~**B61**~~ ✓/~~**B76**~~ ✓/~~**B77**~~ ✓ → **B64** · ~~**B62**~~ ✓ → ~~**B63**~~ ✓ · ~~**B69**~~ ✓ → ~~**B70**~~ ✓ (A4 liderança).
|
||||
- ~~**B43** Início em branco + Quadro~~ — **entregue 2026-07-29** (`/campanha` blank; staff dashboard em `/campanha/quadro`; liderança em `/campanha/contatos`; nav Início+Quadro / Início+Meus contatos; `requireCampaignPageActor` manda leader bloqueado → `/campanha/contatos`; sem migration) · [plano](plans/inicio-em-branco-quadro.md)
|
||||
- ~~**B44** Botão de ação do Início + lista horizontal~~ — **entregue 2026-07-29** (`CampaignHomeActionButton` + `CampaignHomeActionStrip`; Tooltip fine / long-press Drawer coarse; montado no Início por B45 ✓) · ~1d · Janela 1 · Impeccable C · [plano](plans/botao-acao-inicio-strip.md)
|
||||
- ~~**B45** Catálogo de ações por persona~~ — **entregue 2026-07-29** (`src/lib/campaignHomeActions.ts` + `CampaignHomeActions`; staff 6 ações com `uncovered-municipalities` → `?coverage=sem_assessor&sort=votos`; leader 2 com `my-contacts` → `/campanha/contatos`; demais inertes; ilha client por limite RSC↔ícones Lucide) · [plano](plans/catalogo-acoes-inicio-por-persona.md)
|
||||
|
|
@ -95,7 +95,7 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re
|
|||
- **B76** ~~Corrigir coerência do wizard de ajuste de votos~~ — **entregue 2026-07-30** (`useEffect` preserva `violationEditedScenario` no jump ao cenário violado; limpa ao editar draft/atalho; unit `wizardExpectedVotesStep.unit.spec.ts`) · Depende de B61 ✓ · [plano](plans/corrigir-coerencia-wizard-ajuste-votos.md) · UX de três passos → **B77**
|
||||
- **B77** ~~Simplificar wizard ajuste de votos~~ — **entregue 2026-07-30** (`WizardExpectedVotesStep` reescrito: três cenários numa tela com `VoteEstimateScenarioInputs` compact + strip + atalhos no foco; validação só no confirm com `Alert` + highlight; POST batch inalterado; `?cenario=` legado redireciona para URL canônica; órfãos do ritual B61 removidos de `wizardVoteEstimate`/`campaignActionRoutes`) · Depende de B61 ✓ / B76 ✓ · soft ~~B75~~ ✓ · ~0,75–1d · Janela 1 · Impeccable B · [plano](plans/simplificar-ajuste-votos-wizard.md)
|
||||
- ~~**B62** Simplificar modelo de sinal~~ — **entregue 2026-07-29** (sinal = tipo + texto; migration drop `signal_source`/`triangulated`; labels mesa sem "broker" — `Rede esfriou`/`Adversário apareceu`/`Alguém pediu algo`; E11 sem dimensão triangulado: presença = confirmado, **triage 1**; forms/lista/detalhe/dossiê alinhados; e2e stale-B43 corrigido de bônus) · [plano](plans/simplificar-modelo-sinal.md)
|
||||
- **B63** Wizard registrar sinal (grid de tipos + Drawer info + texto + “Pular” se origem ≠ register-signal) · Depende de B59 + B62 · soft B60 · ~1–1,25d · Janela 1 · Impeccable C · [plano](plans/wizard-registro-sinal.md)
|
||||
- ~~**B63** Wizard registrar sinal (grid de tipos + Drawer info + texto + “Pular” se origem ≠ register-signal)~~ — **entregue 2026-07-30** (`WizardSignalTypeStep` + `WizardSignalBodyStep`; contrato `?signalType=` + `?entryAction=`; `municipalitySignalTypeMeta` + `wizardSignalUi`; write via `createMunicipalityListSignalFormAction`; standalone → Início + toast; sem migration) · Depende de B59 ✓ + B62 ✓ · soft B60 ✓ · ~1–1,25d · Janela 1 · Impeccable C · [plano](plans/wizard-registro-sinal.md)
|
||||
- **B64** Wizard mudar tendência (grid colorido ≠ atual + Drawer info + justificativa com prefill/Limpar/Salvar + “Pular” sempre) · Depende de B59 · soft B60 · soft B24 ✓ · ~1–1,25d · Janela 1 · Impeccable C · [plano](plans/wizard-mudar-tendencia.md)
|
||||
- ~~**B69** Simplificar modelo de liderança (`exclusive` default true; dropar setor/`consentNote`; **org fica** — ramificação política, decisão 2026-07-30)~~ ✓ (entregue 2026-07-30) · Depende de — · Janela 1 · [plano](plans/simplificar-modelo-lideranca.md)
|
||||
- ~~**B70** Wizard atualizar liderança (grid 2/3/4 + form curto + skip + Continuar dirty + focus)~~ — **entregue 2026-07-30** (`WizardLeadershipStep` + `WizardLeadershipForm`; `loadWizardLeadershipTiles`; `updateLeadershipWizard`/`createLeadershipWizard` com Contact+Leadership; `entryAction` em `campaignActionRoutes`; e2e smoke) · Depende de B59 + B69 ✓ · soft B60 ✓ · ~1,25–1,5d · Janela 1 · Impeccable C · [plano](plans/wizard-atualizar-lideranca.md)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { notFound, redirect } from 'next/navigation'
|
||||
|
||||
import { WizardLeadershipStep } from '@/components/campaign/leadership/WizardLeadershipStep'
|
||||
import { WizardSignalBodyStep } from '@/components/campaign/municipality/WizardSignalBodyStep'
|
||||
import { WizardSignalTypeStep } from '@/components/campaign/municipality/WizardSignalTypeStep'
|
||||
import { WizardExpectedVotesStep } from '@/components/campaign/shared/WizardExpectedVotesStep'
|
||||
import { WizardMunicipalitySearchStep } from '@/components/campaign/shared/WizardMunicipalitySearchStep'
|
||||
import { WizardMunicipalitySelectedStub } from '@/components/campaign/shared/WizardMunicipalitySelectedStub'
|
||||
|
|
@ -10,10 +12,13 @@ import {
|
|||
isCampaignWizardActionSlug,
|
||||
parseWizardEntryActionParam,
|
||||
parseWizardMunicipioParam,
|
||||
resolveWizardSignalTypeParam,
|
||||
WIZARD_ENTRY_ACTION_QUERY_KEY,
|
||||
WIZARD_MUNICIPIO_QUERY_KEY,
|
||||
WIZARD_SCENARIO_QUERY_KEY,
|
||||
WIZARD_SIGNAL_TYPE_QUERY_KEY,
|
||||
wizardActionHref,
|
||||
wizardSignalHref,
|
||||
} from '@/lib/campaignActionRoutes'
|
||||
import { CAMPAIGN_HOME } from '@/lib/campaignPaths'
|
||||
import { toVoteEstimateScenarioViewModel } from '@/lib/voteEstimate'
|
||||
|
|
@ -94,5 +99,40 @@ export default async function CampaignActionWizardPage({
|
|||
)
|
||||
}
|
||||
|
||||
if (slug === CAMPAIGN_WIZARD_ACTION_SLUGS['register-signal']) {
|
||||
const entryAction = parseWizardEntryActionParam(
|
||||
resolvedSearchParams[WIZARD_ENTRY_ACTION_QUERY_KEY],
|
||||
)
|
||||
const { signalType, invalid } = resolveWizardSignalTypeParam(
|
||||
resolvedSearchParams[WIZARD_SIGNAL_TYPE_QUERY_KEY],
|
||||
)
|
||||
|
||||
if (invalid) {
|
||||
redirect(wizardSignalHref(slug, municipalitySlug, undefined, entryAction))
|
||||
}
|
||||
|
||||
if (signalType) {
|
||||
return (
|
||||
<WizardSignalBodyStep
|
||||
actionSlug={slug}
|
||||
municipalityId={municipality.id}
|
||||
municipalityName={municipality.name}
|
||||
municipalitySlug={municipality.slug}
|
||||
signalType={signalType}
|
||||
entryAction={entryAction}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<WizardSignalTypeStep
|
||||
actionSlug={slug}
|
||||
municipalityName={municipality.name}
|
||||
municipalitySlug={municipality.slug}
|
||||
entryAction={entryAction}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return <WizardMunicipalitySelectedStub actionSlug={slug} municipalityName={municipality.name} />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
parsePoliticalTrendStatusFormValue,
|
||||
} from '@/lib/schemas/municipality'
|
||||
import { parseMunicipalitySignalType } from '@/lib/schemas/municipalityUpdate'
|
||||
import { WIZARD_SIGNAL_SAVED_MESSAGE } from '@/lib/wizardSignalUi'
|
||||
import {
|
||||
runCampaignFormAction,
|
||||
type CampaignFormActionState,
|
||||
|
|
@ -100,7 +101,7 @@ export const createMunicipalityListSignalFormAction = async (
|
|||
signalType: parseMunicipalitySignalType(optionalFormText(formData, 'signalType')),
|
||||
})
|
||||
revalidateMunicipalityListPaths({ slug: optionalMunicipalitySlugFromForm(formData) })
|
||||
return { message: 'Sinal registrado.' }
|
||||
return { message: WIZARD_SIGNAL_SAVED_MESSAGE }
|
||||
},
|
||||
genericMessage: 'Não foi possível registrar o sinal. Verifique seu acesso e tente novamente.',
|
||||
})
|
||||
|
|
|
|||
116
src/components/campaign/municipality/WizardSignalBodyStep.tsx
Normal file
116
src/components/campaign/municipality/WizardSignalBodyStep.tsx
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useActionState } from 'react'
|
||||
|
||||
import { createMunicipalityListSignalFormAction } from '@/app/(campaign)/campanha/(app)/municipios/municipalityStaffFormActions'
|
||||
import { CampaignFormActionMessage } from '@/components/campaign/shared/CampaignFormActionMessage'
|
||||
import { CampaignWizardShell } from '@/components/campaign/shared/CampaignWizardShell'
|
||||
import { useCampaignFormSuccessToast } from '@/components/campaign/shared/useCampaignFormSuccessToast'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import type { CampaignWizardActionId } from '@/lib/campaignActionRoutes'
|
||||
import { wizardSignalHref } from '@/lib/campaignActionRoutes'
|
||||
import { CAMPAIGN_HOME } from '@/lib/campaignPaths'
|
||||
import type { MunicipalitySignalType } from '@/lib/schemas/municipalityUpdate'
|
||||
import { municipalitySignalTypeLabels } from '@/lib/schemas/municipalityUpdate'
|
||||
import {
|
||||
shouldShowWizardSignalSkip,
|
||||
WIZARD_SIGNAL_BODY_STEP_TITLE_PREFIX,
|
||||
WIZARD_SIGNAL_SAVE_LABEL,
|
||||
WIZARD_SIGNAL_SKIP_LABEL,
|
||||
wizardSignalSkipHref,
|
||||
} from '@/lib/wizardSignalUi'
|
||||
|
||||
type WizardSignalBodyStepProps = {
|
||||
actionSlug: string
|
||||
municipalityId: number
|
||||
municipalityName: string
|
||||
municipalitySlug: string
|
||||
signalType: MunicipalitySignalType
|
||||
entryAction?: CampaignWizardActionId
|
||||
}
|
||||
|
||||
export const WizardSignalBodyStep = ({
|
||||
actionSlug,
|
||||
municipalityId,
|
||||
municipalityName,
|
||||
municipalitySlug,
|
||||
signalType,
|
||||
entryAction,
|
||||
}: WizardSignalBodyStepProps) => {
|
||||
const router = useRouter()
|
||||
const [state, submitAction, isPending] = useActionState(
|
||||
createMunicipalityListSignalFormAction,
|
||||
{},
|
||||
)
|
||||
const showSkip = shouldShowWizardSignalSkip(entryAction)
|
||||
const stepTitle = `${WIZARD_SIGNAL_BODY_STEP_TITLE_PREFIX}: ${municipalitySignalTypeLabels[signalType]}`
|
||||
|
||||
useCampaignFormSuccessToast(state, () => {
|
||||
router.push(CAMPAIGN_HOME)
|
||||
})
|
||||
|
||||
return (
|
||||
<CampaignWizardShell
|
||||
stepTitle={stepTitle}
|
||||
previousHref={wizardSignalHref(actionSlug, municipalitySlug, undefined, entryAction)}
|
||||
municipalityLabel={municipalityName}
|
||||
trailingAction={
|
||||
showSkip ? (
|
||||
<Button variant="link" size="sm" className="h-auto px-2 py-1 text-xs" asChild>
|
||||
<Link href={wizardSignalSkipHref()}>{WIZARD_SIGNAL_SKIP_LABEL}</Link>
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<form
|
||||
action={submitAction}
|
||||
className="flex flex-col gap-6"
|
||||
aria-busy={isPending || undefined}
|
||||
data-pending={isPending ? '' : undefined}
|
||||
>
|
||||
<input type="hidden" name="municipalityId" value={municipalityId} />
|
||||
<input type="hidden" name="municipalitySlug" value={municipalitySlug} />
|
||||
<input type="hidden" name="signalType" value={signalType} />
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="wizard-signal-body" className="text-sm font-medium">
|
||||
O que aconteceu?
|
||||
</label>
|
||||
<Textarea
|
||||
id="wizard-signal-body"
|
||||
name="body"
|
||||
rows={5}
|
||||
maxLength={5000}
|
||||
required
|
||||
disabled={isPending}
|
||||
className="min-h-28"
|
||||
placeholder="Descreva o fato com o contexto que a equipe precisa lembrar depois."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{state.status !== 'success' ? <CampaignFormActionMessage state={state} /> : null}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={isPending} className="min-h-11 min-w-[7rem]">
|
||||
{isPending ? (
|
||||
<>
|
||||
<Spinner data-icon="inline-start" aria-hidden="true" />
|
||||
Salvando…
|
||||
</>
|
||||
) : (
|
||||
WIZARD_SIGNAL_SAVE_LABEL
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div aria-live="polite" className="sr-only">
|
||||
{isPending ? 'Salvando sinal.' : null}
|
||||
</div>
|
||||
</form>
|
||||
</CampaignWizardShell>
|
||||
)
|
||||
}
|
||||
129
src/components/campaign/municipality/WizardSignalTypeStep.tsx
Normal file
129
src/components/campaign/municipality/WizardSignalTypeStep.tsx
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
'use client'
|
||||
|
||||
import { Info } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { CampaignWizardShell } from '@/components/campaign/shared/CampaignWizardShell'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from '@/components/ui/Drawer'
|
||||
import type { CampaignWizardActionId } from '@/lib/campaignActionRoutes'
|
||||
import { wizardActionHref, wizardSignalHref } from '@/lib/campaignActionRoutes'
|
||||
import { municipalitySignalTypeMeta } from '@/lib/municipalitySignalTypeMeta'
|
||||
import type { MunicipalitySignalType } from '@/lib/schemas/municipalityUpdate'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
shouldShowWizardSignalSkip,
|
||||
WIZARD_SIGNAL_SKIP_LABEL,
|
||||
WIZARD_SIGNAL_TYPE_STEP_TITLE,
|
||||
wizardSignalSkipHref,
|
||||
} from '@/lib/wizardSignalUi'
|
||||
|
||||
type WizardSignalTypeStepProps = {
|
||||
actionSlug: string
|
||||
municipalityName: string
|
||||
municipalitySlug: string
|
||||
entryAction?: CampaignWizardActionId
|
||||
}
|
||||
|
||||
export const WizardSignalTypeStep = ({
|
||||
actionSlug,
|
||||
municipalityName,
|
||||
municipalitySlug,
|
||||
entryAction,
|
||||
}: WizardSignalTypeStepProps) => {
|
||||
const router = useRouter()
|
||||
const [infoType, setInfoType] = useState<MunicipalitySignalType | null>(null)
|
||||
const infoEntry = infoType
|
||||
? municipalitySignalTypeMeta.find((entry) => entry.type === infoType)
|
||||
: null
|
||||
const showSkip = shouldShowWizardSignalSkip(entryAction)
|
||||
|
||||
return (
|
||||
<>
|
||||
<CampaignWizardShell
|
||||
stepTitle={WIZARD_SIGNAL_TYPE_STEP_TITLE}
|
||||
previousHref={wizardActionHref(actionSlug)}
|
||||
municipalityLabel={municipalityName}
|
||||
contentAlign="end"
|
||||
trailingAction={
|
||||
showSkip ? (
|
||||
<Button variant="link" size="sm" className="h-auto px-2 py-1 text-xs" asChild>
|
||||
<Link href={wizardSignalSkipHref()}>{WIZARD_SIGNAL_SKIP_LABEL}</Link>
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<ul className="grid list-none grid-cols-2 gap-3 md:grid-cols-3">
|
||||
{municipalitySignalTypeMeta.map((entry) => {
|
||||
const Icon = entry.icon
|
||||
return (
|
||||
<li key={entry.type} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex aspect-square w-full flex-col justify-between rounded-lg border border-border bg-transparent p-3 pr-10 text-left',
|
||||
'transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
)}
|
||||
onClick={() =>
|
||||
router.push(
|
||||
wizardSignalHref(actionSlug, municipalitySlug, entry.type, entryAction),
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon className="size-5 shrink-0 text-foreground" aria-hidden />
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-sm font-medium text-foreground">{entry.label}</span>
|
||||
<span className="line-clamp-2 text-xs text-muted-foreground">
|
||||
{entry.shortDescription}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-1 top-1 size-8"
|
||||
aria-label={`Informações sobre ${entry.label}`}
|
||||
onClick={() => setInfoType(entry.type)}
|
||||
>
|
||||
<Info className="size-4" aria-hidden />
|
||||
</Button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</CampaignWizardShell>
|
||||
|
||||
<Drawer open={infoEntry != null} onOpenChange={(open) => !open && setInfoType(null)}>
|
||||
<DrawerContent>
|
||||
{infoEntry ? (
|
||||
<>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>{infoEntry.label}</DrawerTitle>
|
||||
<DrawerDescription>{infoEntry.shortDescription}</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<div className="px-4 pb-2">
|
||||
<p className="text-sm text-muted-foreground">{infoEntry.infoContent}</p>
|
||||
</div>
|
||||
<DrawerFooter>
|
||||
<DrawerClose render={<Button variant="outline" className="min-h-11 w-full" />}>
|
||||
Fechar
|
||||
</DrawerClose>
|
||||
</DrawerFooter>
|
||||
</>
|
||||
) : null}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,5 +1,10 @@
|
|||
/** Client-safe entry paths for UX-1 action wizards under `/campanha/acoes`. */
|
||||
|
||||
import {
|
||||
parseMunicipalitySignalType,
|
||||
type MunicipalitySignalType,
|
||||
} from '@/lib/schemas/municipalityUpdate'
|
||||
|
||||
export const CAMPAIGN_ACTIONS_HOME = '/campanha/acoes' as const
|
||||
|
||||
export type CampaignWizardActionId =
|
||||
|
|
@ -42,6 +47,8 @@ export const WIZARD_MUNICIPIO_QUERY_KEY = 'municipio' as const
|
|||
/** Legacy B61 ritual param — canonical URLs omit it (B77). */
|
||||
export const WIZARD_SCENARIO_QUERY_KEY = 'cenario' as const
|
||||
|
||||
export const WIZARD_SIGNAL_TYPE_QUERY_KEY = 'signalType' as const
|
||||
|
||||
export const WIZARD_ENTRY_ACTION_QUERY_KEY = 'entry' as const
|
||||
|
||||
export type WizardActionHrefOptions = {
|
||||
|
|
@ -85,3 +92,41 @@ export const parseWizardEntryActionParam = (
|
|||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
export const wizardSignalHref = (
|
||||
actionSlug: string,
|
||||
municipalitySlug?: string,
|
||||
signalType?: MunicipalitySignalType,
|
||||
entryAction?: CampaignWizardActionId,
|
||||
): string => {
|
||||
const base = `${CAMPAIGN_ACTIONS_HOME}/${actionSlug}`
|
||||
if (!municipalitySlug) {
|
||||
return base
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({ [WIZARD_MUNICIPIO_QUERY_KEY]: municipalitySlug })
|
||||
if (signalType) {
|
||||
params.set(WIZARD_SIGNAL_TYPE_QUERY_KEY, signalType)
|
||||
}
|
||||
if (entryAction) {
|
||||
params.set(WIZARD_ENTRY_ACTION_QUERY_KEY, entryAction)
|
||||
}
|
||||
return `${base}?${params.toString()}`
|
||||
}
|
||||
|
||||
export const resolveWizardSignalTypeParam = (
|
||||
value: string | string[] | undefined,
|
||||
): { signalType?: MunicipalitySignalType; invalid: boolean } => {
|
||||
const raw = Array.isArray(value) ? value[0] : value
|
||||
const trimmed = raw?.trim()
|
||||
if (!trimmed) {
|
||||
return { signalType: undefined, invalid: false }
|
||||
}
|
||||
|
||||
const signalType = parseMunicipalitySignalType(trimmed)
|
||||
if (signalType) {
|
||||
return { signalType, invalid: false }
|
||||
}
|
||||
|
||||
return { signalType: undefined, invalid: true }
|
||||
}
|
||||
|
|
|
|||
53
src/lib/municipalitySignalTypeMeta.ts
Normal file
53
src/lib/municipalitySignalTypeMeta.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import {
|
||||
CircleHelp,
|
||||
Handshake,
|
||||
ShieldAlert,
|
||||
Snowflake,
|
||||
UserRoundX,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
|
||||
import {
|
||||
municipalitySignalTypeDescriptions,
|
||||
municipalitySignalTypeLabels,
|
||||
municipalitySignalTypes,
|
||||
type MunicipalitySignalType,
|
||||
} from '@/lib/schemas/municipalityUpdate'
|
||||
|
||||
export type MunicipalitySignalTypeMetaEntry = {
|
||||
type: MunicipalitySignalType
|
||||
label: string
|
||||
shortDescription: string
|
||||
infoContent: string
|
||||
icon: LucideIcon
|
||||
}
|
||||
|
||||
const signalTypeIcons: Record<MunicipalitySignalType, LucideIcon> = {
|
||||
invasao: ShieldAlert,
|
||||
esfriamento: Snowflake,
|
||||
visita_adversario: UserRoundX,
|
||||
proposta_broker: Handshake,
|
||||
outro: CircleHelp,
|
||||
}
|
||||
|
||||
const signalTypeInfoContent: Record<MunicipalitySignalType, string> = {
|
||||
invasao:
|
||||
'O adversário está ocupando espaço que era nosso: reunião, base, narrativa ou presença no território. Consequência: a rede pode esfriar ou a leitura do município muda. Use quando a equipe perceber invasão real, não só boatos.',
|
||||
esfriamento:
|
||||
'Aliados pararam de responder, sumiram das conversas ou a rede perdeu ritmo. Consequência: cobertura e pledges podem estagnar. Use quando o silêncio for novo e relevante para a decisão do município.',
|
||||
visita_adversario:
|
||||
'O adversário apareceu no município — visita, agenda pública ou movimento visível. Consequência: pode pressionar lideranças ou mudar a leitura local. Use quando houver fato concreto, não só expectativa.',
|
||||
proposta_broker:
|
||||
'Alguém pediu ou ofereceu algo em troca de apoio, voto ou posicionamento. Consequência: pode virar demanda, risco ou oportunidade de alinhamento. Use quando a conversa for relevante para a campanha.',
|
||||
outro:
|
||||
'Fato importante que não encaixa nos tipos acima, mas merece registro datado. Consequência: alimenta o histórico do município e pode acionar sugestões depois. Use com parcimônia — prefira um tipo específico quando couber.',
|
||||
}
|
||||
|
||||
export const municipalitySignalTypeMeta: MunicipalitySignalTypeMetaEntry[] =
|
||||
municipalitySignalTypes.map((type) => ({
|
||||
type,
|
||||
label: municipalitySignalTypeLabels[type],
|
||||
shortDescription: municipalitySignalTypeDescriptions[type],
|
||||
infoContent: signalTypeInfoContent[type],
|
||||
icon: signalTypeIcons[type],
|
||||
}))
|
||||
19
src/lib/wizardSignalUi.ts
Normal file
19
src/lib/wizardSignalUi.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import type { CampaignWizardActionId } from '@/lib/campaignActionRoutes'
|
||||
import { CAMPAIGN_HOME } from '@/lib/campaignPaths'
|
||||
|
||||
export const WIZARD_SIGNAL_TYPE_STEP_TITLE = 'Que tipo de sinal?' as const
|
||||
|
||||
export const WIZARD_SIGNAL_BODY_STEP_TITLE_PREFIX = 'Detalhar sinal' as const
|
||||
|
||||
export const WIZARD_SIGNAL_SKIP_LABEL = 'Pular registro de sinal' as const
|
||||
|
||||
export const WIZARD_SIGNAL_SAVE_LABEL = 'Salvar' as const
|
||||
|
||||
export const WIZARD_SIGNAL_SAVED_MESSAGE = 'Sinal registrado.' as const
|
||||
|
||||
export const wizardSignalSkipHref = (): string => CAMPAIGN_HOME
|
||||
|
||||
/** Skip is visible only when the wizard was embedded from another flow (not standalone register-signal). */
|
||||
export const shouldShowWizardSignalSkip = (
|
||||
entryAction: CampaignWizardActionId | undefined,
|
||||
): boolean => entryAction != null && entryAction !== 'register-signal'
|
||||
|
|
@ -280,3 +280,76 @@ test.describe('Wizard — atualizar liderança (B70)', () => {
|
|||
await expect(page.getByLabel('Buscar na campanha')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Wizard — registrar sinal (B63)', () => {
|
||||
test('standalone flow: type grid, body step, save returns to Início', async ({
|
||||
campaign,
|
||||
page,
|
||||
}) => {
|
||||
const { fixtures } = campaign
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator', {
|
||||
name: fixtures.value('Coordenadora Geral'),
|
||||
})
|
||||
|
||||
await campaign.login(page, coordinator.email!, coordinator.password)
|
||||
await page.goto('/campanha/acoes/registrar-sinal?municipio=cairu')
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Que tipo de sinal?' })).toBeVisible({
|
||||
timeout: 15000,
|
||||
})
|
||||
await expect(page.getByRole('link', { name: 'Pular registro de sinal' })).toHaveCount(0)
|
||||
|
||||
await page
|
||||
.getByRole('listitem')
|
||||
.filter({ hasText: 'Invasão' })
|
||||
.getByRole('button')
|
||||
.first()
|
||||
.click()
|
||||
await page.waitForURL(/signalType=invasao/)
|
||||
|
||||
await expect(page.getByRole('heading', { name: /Detalhar sinal: Invasão/i })).toBeVisible()
|
||||
|
||||
const body = page.getByLabel('O que aconteceu?')
|
||||
await body.fill('Adversário marcou presença no centro do município.')
|
||||
await page.getByRole('button', { name: 'Salvar' }).click()
|
||||
|
||||
await page.waitForURL(/\/campanha$/)
|
||||
await expect(page.getByText('Sinal registrado.')).toBeVisible()
|
||||
})
|
||||
|
||||
test('info button opens drawer without advancing to body step', async ({ campaign, page }) => {
|
||||
const { fixtures } = campaign
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator', {
|
||||
name: fixtures.value('Coordenadora Geral'),
|
||||
})
|
||||
|
||||
await campaign.login(page, coordinator.email!, coordinator.password)
|
||||
await page.goto('/campanha/acoes/registrar-sinal?municipio=cairu')
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Que tipo de sinal?' })).toBeVisible({
|
||||
timeout: 15000,
|
||||
})
|
||||
|
||||
await page.getByRole('button', { name: /Informações sobre Invasão/i }).click()
|
||||
await expect(page.getByRole('dialog')).toBeVisible()
|
||||
await expect(page.getByText(/O adversário está ocupando espaço/i)).toBeVisible()
|
||||
await expect(page).not.toHaveURL(/signalType=/)
|
||||
|
||||
await page.getByRole('button', { name: 'Fechar' }).click()
|
||||
await expect(page.getByRole('dialog')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('embedded flow shows skip link', async ({ campaign, page }) => {
|
||||
const { fixtures } = campaign
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator', {
|
||||
name: fixtures.value('Coordenadora Geral'),
|
||||
})
|
||||
|
||||
await campaign.login(page, coordinator.email!, coordinator.password)
|
||||
await page.goto('/campanha/acoes/registrar-sinal?municipio=cairu&entryAction=update-votes')
|
||||
|
||||
await expect(page.getByRole('link', { name: 'Pular registro de sinal' })).toBeVisible({
|
||||
timeout: 15000,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -10,7 +10,9 @@ import {
|
|||
isCampaignWizardActionSlug,
|
||||
parseWizardEntryActionParam,
|
||||
parseWizardMunicipioParam,
|
||||
resolveWizardSignalTypeParam,
|
||||
wizardActionHref,
|
||||
wizardSignalHref,
|
||||
} from '@/lib/campaignActionRoutes'
|
||||
|
||||
describe('campaignActionRoutes', () => {
|
||||
|
|
@ -62,7 +64,35 @@ describe('campaignActionRoutes', () => {
|
|||
|
||||
it('parses entry action search param', () => {
|
||||
expect(parseWizardEntryActionParam('update-leadership')).toBe('update-leadership')
|
||||
expect(parseWizardEntryActionParam('update-votes')).toBe('update-votes')
|
||||
expect(parseWizardEntryActionParam('invalid')).toBeUndefined()
|
||||
expect(parseWizardEntryActionParam(undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('builds signal wizard hrefs with optional signalType and entry', () => {
|
||||
expect(wizardSignalHref('registrar-sinal', 'cairu')).toBe(
|
||||
`${CAMPAIGN_ACTIONS_HOME}/registrar-sinal?municipio=cairu`,
|
||||
)
|
||||
expect(wizardSignalHref('registrar-sinal', 'cairu', 'invasao')).toBe(
|
||||
`${CAMPAIGN_ACTIONS_HOME}/registrar-sinal?municipio=cairu&signalType=invasao`,
|
||||
)
|
||||
expect(wizardSignalHref('registrar-sinal', 'cairu', 'esfriamento', 'update-votes')).toBe(
|
||||
`${CAMPAIGN_ACTIONS_HOME}/registrar-sinal?municipio=cairu&signalType=esfriamento&entry=update-votes`,
|
||||
)
|
||||
})
|
||||
|
||||
it('resolves signalType search param with invalid flag', () => {
|
||||
expect(resolveWizardSignalTypeParam(undefined)).toEqual({
|
||||
signalType: undefined,
|
||||
invalid: false,
|
||||
})
|
||||
expect(resolveWizardSignalTypeParam('invasao')).toEqual({
|
||||
signalType: 'invasao',
|
||||
invalid: false,
|
||||
})
|
||||
expect(resolveWizardSignalTypeParam('invalid')).toEqual({
|
||||
signalType: undefined,
|
||||
invalid: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
31
tests/unit/wizardSignalUi.unit.spec.ts
Normal file
31
tests/unit/wizardSignalUi.unit.spec.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { municipalitySignalTypeMeta } from '@/lib/municipalitySignalTypeMeta'
|
||||
import { municipalitySignalTypes } from '@/lib/schemas/municipalityUpdate'
|
||||
import { shouldShowWizardSignalSkip } from '@/lib/wizardSignalUi'
|
||||
|
||||
describe('wizardSignalUi', () => {
|
||||
it('hides skip for standalone register-signal entry', () => {
|
||||
expect(shouldShowWizardSignalSkip(undefined)).toBe(false)
|
||||
expect(shouldShowWizardSignalSkip('register-signal')).toBe(false)
|
||||
})
|
||||
|
||||
it('shows skip when embedded from another wizard action', () => {
|
||||
expect(shouldShowWizardSignalSkip('update-votes')).toBe(true)
|
||||
expect(shouldShowWizardSignalSkip('change-trend')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('municipalitySignalTypeMeta', () => {
|
||||
it('covers every signal type with icon and info copy', () => {
|
||||
expect(municipalitySignalTypeMeta).toHaveLength(municipalitySignalTypes.length)
|
||||
|
||||
for (const type of municipalitySignalTypes) {
|
||||
const entry = municipalitySignalTypeMeta.find((item) => item.type === type)
|
||||
expect(entry?.label.length).toBeGreaterThan(0)
|
||||
expect(entry?.shortDescription.length).toBeGreaterThan(0)
|
||||
expect(entry?.infoContent.length).toBeGreaterThan(20)
|
||||
expect(entry?.icon).toBeTruthy()
|
||||
}
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue