mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
fix(B32-B34-F1): clamp nunca esconde todos os chips + key warning do head (Closes #1042)
This commit is contained in:
parent
ee9c9ca068
commit
069efd37e0
5 changed files with 141 additions and 15 deletions
1
docs/changelog/2026-09-15-b32-b34-f1.md
Normal file
1
docs/changelog/2026-09-15-b32-b34-f1.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
**B32-B34-F1 — UI pós-verify: clamp podia esconder TODOS os chips e warning de key no head (2026-09-15):** dois débitos que ficaram fora do escopo da #1036, ambos no dono compartilhado. **F1 — `RelationChipCell`:** a medição de overflow (`useLayoutEffect`, `RelationChipCell.tsx`) começava com `fitting ≥ 1` e o loop `while (fitting > 0 && fitting < chipElements.length)` podia descer até **0** quando os chips largos preenchem todas as linhas numa coluna estreita e o espaço reservado do toggle + input de busca (128px do `min-w-32`) não cabe ao lado de nenhum chip de cauda — a célula renderizava **sem chip nenhum**, só "Ver mais…" (snapshot de falha do e2e B34; o `expandCollapsedChips()` era contorno). Fix: piso de um no próprio guard (`fitting > 1`) — o toggle e o input passam a quebrar para a linha seguinte (a linha é `flex-wrap`, pós-medição não há clamp), nunca zero chips. Pin unitário novo em `relationChipCellCollapse.unit.spec.tsx` ativando o ramo de `min-width` que o stub antigo pulava (INPUT width 0): com o loop antigo o stub estreito dá 0 chips (RED verificado), com o piso dá 1 e expande/recolhe; os 4 pins do contrato de 3 linhas seguem verdes. **F2 — `CampaignSortableHead`:** o `filter` criado por componente **server** (`LeadershipSortableHead`, sem `'use client'` desde 7a450bc8) era filho keyless do array estático `{labeledSort}{filter}`, e o React dev acusava `unique key` a cada render de `/campanha/liderancas` — o guard de `console.error` do e2e derrubava todo run em dev local (por isso a #1036 precisou de `E2E_PROD=1`). Fix no dono compartilhado: Fragments com `key` nos dois slots (cobre também o mesmo padrão latente em `StateDeputySortableHead`), sem nó novo no DOM. Prova: `campaignLeaderships` em **dev local sem `E2E_PROD=1`** 2/2 (RED comprovado revertendo o head: o warning exato derruba o guard) e mesma superfície `campaignPeople`+`campaignMunicipalities` 34/35 (a única falha, C131 `Todas as zonas`, reproduz na árvore limpa — pré-existente, dona #882); `gate:fast` verde (lint + typecheck + 3350 unit). Defers com gatilho no plano (exemption de `campaign/shared` no manifesto e2e; corrida de DDL do allocator sob workers paralelos). Plano: `docs/plans/b32-b34-f1-ui-pos-verify-impl.md`.
|
||||
67
docs/plans/b32-b34-f1-ui-pos-verify-impl.md
Normal file
67
docs/plans/b32-b34-f1-ui-pos-verify-impl.md
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
# Impl: UX pós-B32/B34 — chip oculto pelo clamp e key warning do head (débito da entrega #1036)
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-09-15
|
||||
Issue: #1042
|
||||
Intenção: docs/plans/ux-pos-b32-b34-lista-liderancas-impl.md
|
||||
Appetite restante: ~0,5 dia eng (herdado — duas fases curtas, sem corte)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** a célula de relação nunca esconde **todos** os chips atrás de "Ver mais…" quando há chip (`chips.length > 0` ⇒ piso de 1 visível); e o e2e de `/campanha/liderancas` volta a rodar em **dev local sem `E2E_PROD=1`** (console guard ativo) sem o warning React de key.
|
||||
- **O que NÃO negociar:** sem schema/migration/Payload config/access/Consent/server action; guard de `console.error` (`tests/e2e/fixtures/e2eTest.ts:70-134`) intocado; nenhuma lista consumidora muda além do comportamento compartilhado; copy pt-BR, identificadores em inglês.
|
||||
- **O que reavaliar:** a intenção afirma que o colapso a 0 ocorre "com dois chips" — **não é reprodutível**: com 2 chips, `fitting === chips.length` e o loop (`RelationChipCell.tsx:521`) nunca roda. A geometria real é: chips largos preenchendo as linhas (3 chips um por linha, ou 4+ em linhas cheias) numa coluna estreita **com o ramo de `min-width` do input ativo** (128px, `min-w-32`) — aí todo chip de cauda falha o encaixe e `fitting` desce 3→2→1→0, deixando `visibleChips` vazio (`:742-745`) só com o toggle (`:787-815`).
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["F1 — e2e #1036: célula só com 'Ver mais…'"] --> B["loop de fitting<br/>RelationChipCell.tsx:521-525"]
|
||||
B --> C["guard fitting > 1<br/>piso de 1 chip"]
|
||||
C --> D["unit pin novo<br/>ramo min-width 128px"]
|
||||
E["F2 — console.error dev: key warning"] --> F["array children<br/>CampaignSortableHead.tsx:86-96"]
|
||||
F --> G["Fragment keyed<br/>sort / filter"]
|
||||
G --> H["e2e local dev<br/>sem E2E_PROD"]
|
||||
```
|
||||
|
||||
**Opções consideradas:** F1: A (piso 1 no loop) | B (extrair o packing para helper puro em `src/lib/*`) | C (toggle virar chip "+N" via `overflowToggleLabel`). F2: A (keys no owner compartilhado) | B (key em cada caller) | C (silenciar o guard).
|
||||
**Recomendação:** F1-A + F2-A — em F1, `while (fitting > 0 …)` → `while (fitting > 1 …)` no owner da medição, com comentário citando a evidência (Issue #1042); o piso é garantido sem `Math.max(1, fitting)`, que seria código morto (o `fitting` inicial já é ≥1, pois `chipRects[0].top <= lastVisibleTop + 1`). Em F2, chavear os slots no próprio `CampaignSortableHead` corrige LSH e `StateDeputySortableHead` de uma vez e não muda o DOM (Fragment não cria nó).
|
||||
**Rejeitadas:** F1-B — 1 call site, pass-through raso (DRY <3); o spec unitário já stuba o contrato de DOM e pina a regressão direto, sem módulo novo sem 2º consumidor. F1-C — `overflowToggleLabel` é escolha de display por consumidor e não impede 0 chips (e o clamp/contrato de 3 linhas está pinado). F2-B — twinning: o próximo caller RSC reintroduz o warning. F2-C — proibido pela intenção.
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`RelationChipCell`** (`src/components/campaign/shared/RelationChipCell.tsx:521`): guard do loop → `fitting > 1` + comentário. A linha é `flex-wrap` (`:1102`) e pós-medição `clamping` é `false` (`:751`), então toggle+input caem na linha seguinte quando não cabem ao lado do chip — pior caso 2 linhas, nunca 0 chips. Sem clamp novo.
|
||||
- **`CampaignSortableHead`** (`src/components/campaign/shared/CampaignSortableHead.tsx:86-96`): `<Fragment key="sort">{labeledSort}</Fragment>` / `<Fragment key="filter">{filter}</Fragment>` (import de valor `Fragment` de `react`, hoje só há import de tipos). Classes `align`/`filter` e o ramo sem `filter` (filho único) intocados; LSH (`LeadershipSortableHead.tsx:51-55`) e `StateDeputySortableHead.tsx:50-58` não são editados.
|
||||
- **Teste** (`tests/unit/relationChipCellCollapse.unit.spec.tsx`): caso novo com geometria mutável por teste (ver Fase 1). **Migration:** sem migration. **Access / Consent:** N/A (sem escrita/leitura de PII). **UI:** Impeccable B — nenhuma tela nova; sem shape→craft.
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **F1 — piso de 1 + pin unitário (~0,3 dia):** editar o loop; no spec, tornar os stubs de geometria mutáveis por teste (`chipsPerLine`/`rowRight`/`inputWidth` lidos pelo stub das linhas 117-128; reset no `afterEach`) e adicionar o caso: 4 chips um-por-linha, `rowRight` estreito, INPUT com rect width > 0 e `vi.spyOn(window, 'getComputedStyle').mockReturnValue({ minWidth: '128px' } as CSSStyleDeclaration)` (restaurar com `vi.restoreAllMocks()`). Com o loop antigo `fitting` chega a 0; com o fix: `chipCount === 1`, chip visível, "Ver mais…" presente e expandir (4) / recolher (1) funciona. Manter os 4 pins existentes verdes (12/16, 8, 12). Rodar `pnpm test:unit tests/unit/relationChipCellCollapse.unit.spec.ts`.
|
||||
2. **F2 — keys no owner + e2e dev (~0,2 dia):** aplicar os Fragments; rodar **em dev local, sem `E2E_PROD=1`** (guard ativo; qualquer `console.error` falha): `pnpm test:e2e --no-deps -- tests/e2e/campaignLeaderships.e2e.spec.ts` (porta do `.env.test.local` do worktree; receita documentada em `playwright.config.ts:141-153`). Prova: os 2 testes do spec verdes e nenhum outro `console.error` no relatório agregado do guard.
|
||||
3. **Gates:** `pnpm gate:fast` a cada iteração; `pnpm push` (o skill fecha o PR); entrada única em `docs/changelog/2026-09-15-b32-b34-f1.md`; não commitar agregados nem artefatos de build.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- Não redesenhar o clamp nem alterar o contrato de 3 linhas pinado pelos 4 unit testes; não tocar as 4 listas consumidoras além do compartilhado.
|
||||
- Não remover/refatorar `expandCollapsedChips()` (`campaignLeaderships.e2e.spec.ts:99-115`) — e2e é discricionário (OPS72) e o débito é a UI.
|
||||
- Não extrair helper `src/lib/*`, não chavear nos callers, não refatorar outros heads, não silenciar/afrouxar o guard.
|
||||
- Sem schema, migration, Payload config, access, Consent ou server action.
|
||||
- **Defer + gatilho — `src/components/campaign/shared/*` fora do manifesto e2e:** um diff de `RelationChipCell`/`CampaignSortableHead` roda só `selected` + smoke `campaignHomeActions` (nunca zero) e a granularidade por domínio é deliberada (`E2E_MANIFEST_DOMAIN_EXEMPT`); mapear acordaria 5+ famílias por diff. Gatilho: 2ª regressão de um arquivo de `campaign/shared/` que escape do PR para o `verify` → mapear esse arquivo às specs consumidoras (precedente `shared/CampaignListOmnibox`).
|
||||
- **Defer + gatilho — corrida de DDL do `ensureAllocatorSchema` sob workers paralelos (local):** `CREATE TABLE/SEQUENCE IF NOT EXISTS` ainda pode perder para o `23505` embrulhado (o catch em `tests/helpers/campaignMunicipalityAllocator.ts:58` só olha `error.code`); workaround `--workers=1` já documentado. Gatilho: 1ª ocorrência no CI/`verify` ou 2ª local com o workaround → endurecer o catch (`error.cause?.code === '23505'`).
|
||||
- **Pré-existente (não é deste diff, rastreado na #882):** `campaignPeople.e2e.spec.ts:206` (C131, `Todas as zonas`) falha em dev local também na árvore limpa (mudanças em stash) — owner é a #882.
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Piso de 1 deixa toggle+input na 2ª linha** quando não cabem ao lado do chip: trade-off aceito (linha `flex-wrap`, sem clamp pós-medição; pior caso 2 linhas); os pins 12/16 e 12/12 provam que a geometria larga não regride.
|
||||
- **Warning F2 de outra origem:** o guard agrega todos os failures; se o e2e apontar outro `console.error`, corrigir o owner — nunca o guard.
|
||||
- **E2E dev local é caro (cold compile):** usar `--no-deps` e a porta do `.env.test.local`; compilação lenta/falha de ambiente não é regressão do fix.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto da intenção coberto: `chips.length > 0` ⇒ ≥1 chip no DOM; e2e local dev verde sem `E2E_PROD=1`.
|
||||
- [ ] Invariantes AGENTS/engineering-standards: sem schema/migration/config/access/Consent/action; guard intocado; ids em inglês e copy pt-BR.
|
||||
- [ ] Testes de domínio previstos: unit pin do ramo `min-width` (loop antigo → 0) + 4 pins antigos verdes; e2e dev B32/B34 verde; `pnpm gate:fast` verde.
|
||||
|
||||
## Decision quality
|
||||
|
||||
Score 4/5 — (1) decisões caras (extrair módulo vs. não; key no owner vs. caller) com rejeitadas explícitas; (2) cabe no appetite ~0,5 dia; (3) rabbit holes nomeados; (4) depth check: reusa os owners existentes (medição e head), sem pass-through novo; (5) intenção satisfeita — outcome intocado, só o mecanismo real da F1 foi corrigido na leitura. Não é 5 porque o piso de 1 é pinado por stub de DOM (não pelo layout real); a prova fina fica no e2e quando o conteúdo colapsa.
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
'use client'
|
||||
|
||||
import { ChevronDownIcon, ChevronsUpDownIcon, ChevronUpIcon } from 'lucide-react'
|
||||
import type { PointerEvent, ReactElement, ReactNode, Ref } from 'react'
|
||||
import { Fragment, type PointerEvent, type ReactElement, type ReactNode, type Ref } from 'react'
|
||||
|
||||
import { CampaignTransitionAnchor } from '@/components/campaign/shared/CampaignListPending'
|
||||
import { TableHead } from '@/components/ui/Table'
|
||||
|
|
@ -91,8 +91,13 @@ export const CampaignSortableHead = ({
|
|||
align === 'center' && 'justify-center',
|
||||
)}
|
||||
>
|
||||
{labeledSort}
|
||||
{filter}
|
||||
{/* Keyed slots (Issue #1042): `filter` is created by a server
|
||||
component (e.g. LeadershipSortableHead) and arrives without a
|
||||
validated key, so reconciling this array warned "unique key" in
|
||||
the dev build and failed the e2e console guard. Keying both
|
||||
slots covers the array; the Fragments add no DOM. */}
|
||||
<Fragment key="sort">{labeledSort}</Fragment>
|
||||
<Fragment key="filter">{filter}</Fragment>
|
||||
</div>
|
||||
) : (
|
||||
labeledSort
|
||||
|
|
|
|||
|
|
@ -518,7 +518,12 @@ export const RelationChipCell = ({
|
|||
? Number.parseFloat(getComputedStyle(inputElement).minWidth) || 0
|
||||
: 0
|
||||
const trailingWidth = toggleWidth + CHIP_GAP_PX + inputWidth
|
||||
while (fitting > 0 && fitting < chipElements.length) {
|
||||
// Floor of one (Issue #1042): when wide chips fill every line, the tail
|
||||
// chip always fails the reservation and the old `fitting > 0` guard walked
|
||||
// all the way to 0 — a cell with links rendered as just "Ver mais…", every
|
||||
// chip out of the DOM. One chip always survives; the toggle and the input
|
||||
// wrap to the next line when they do not fit beside it.
|
||||
while (fitting > 1 && fitting < chipElements.length) {
|
||||
const trailing = chipRects[fitting - 1]
|
||||
if (trailing.right + CHIP_GAP_PX + trailingWidth <= rowRect.right) break
|
||||
fitting -= 1
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { createElement } from 'react'
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
RelationChipCell,
|
||||
|
|
@ -22,11 +22,13 @@ import type { CampaignFormActionState } from '@/utilities/campaignFormActionErro
|
|||
* "Ver mais…"/"Ver menos" toggling between the slice and the full set.
|
||||
*
|
||||
* The stub keys off attributes, mirroring how the measurement walks the DOM:
|
||||
* `[data-relation-chip]` spans get index-derived rects (line = floor(idx/4)),
|
||||
* `[data-relation-toggle]` reserves a small width on the last line, INPUT is
|
||||
* width 0 (so the `minWidth` branch is skipped), and every other element is the
|
||||
* row, whose generously wide `right` keeps the trailing-width loop from
|
||||
* shrinking the slice.
|
||||
* `[data-relation-chip]` spans get index-derived rects (line = floor(idx/N),
|
||||
* N = 4 by default), `[data-relation-toggle]` reserves a small width on the
|
||||
* last line, INPUT is width 0 by default (so the `minWidth` branch is
|
||||
* skipped), and every other element is the row, `right` = 500 by default
|
||||
* (generously wide, keeping the trailing-width loop from shrinking the slice).
|
||||
* The `// Issue #1042` case below overrides all three, because the floor of
|
||||
* one only matters where the defaults do not reach.
|
||||
*/
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
|
|
@ -40,9 +42,18 @@ const CHIPS_PER_LINE = 4
|
|||
const ROW_RIGHT = 500
|
||||
const TOGGLE_WIDTH = 40
|
||||
|
||||
/**
|
||||
* Issue #1042 — the geometry is per-test so the regression can narrow the
|
||||
* column and make the search input reserve its real `min-w-32` (128px): that
|
||||
* reservation is what used to walk `fitting` to zero. Reset in `beforeEach`.
|
||||
*/
|
||||
let chipsPerLine = CHIPS_PER_LINE
|
||||
let rowRight = ROW_RIGHT
|
||||
let inputWidth = 0
|
||||
|
||||
const chipRect = (index: number) => {
|
||||
const line = Math.floor(index / CHIPS_PER_LINE)
|
||||
const col = index % CHIPS_PER_LINE
|
||||
const line = Math.floor(index / chipsPerLine)
|
||||
const col = index % chipsPerLine
|
||||
const top = line * (CHIP_HEIGHT + CHIP_GAP_PX)
|
||||
const left = col * (CHIP_WIDTH + CHIP_GAP_PX)
|
||||
return {
|
||||
|
|
@ -69,6 +80,7 @@ const indexAmongChipSiblings = (element: Element): number => {
|
|||
}
|
||||
|
||||
const originalGetBoundingClientRect = Element.prototype.getBoundingClientRect
|
||||
const originalGetComputedStyle = window.getComputedStyle.bind(window)
|
||||
|
||||
const chip = (id: number): RelationChip => ({
|
||||
key: `chip-${id}`,
|
||||
|
|
@ -122,13 +134,22 @@ beforeAll(() => {
|
|||
return { width: TOGGLE_WIDTH } as DOMRect
|
||||
}
|
||||
if (this.tagName === 'INPUT') {
|
||||
return { width: 0, height: CHIP_HEIGHT } as DOMRect
|
||||
return { width: inputWidth, height: CHIP_HEIGHT } as DOMRect
|
||||
}
|
||||
return { right: ROW_RIGHT, width: ROW_RIGHT } as DOMRect
|
||||
return { right: rowRight, width: rowRight } as DOMRect
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(cleanup)
|
||||
beforeEach(() => {
|
||||
chipsPerLine = CHIPS_PER_LINE
|
||||
rowRight = ROW_RIGHT
|
||||
inputWidth = 0
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllGlobals()
|
||||
|
|
@ -179,4 +200,31 @@ describe('RelationChipCell collapse contract (B170 F1)', () => {
|
|||
expect(screen.getByText('Estado 12')).toBeTruthy()
|
||||
expect(screen.queryByRole('button', { name: /Ver mais/ })).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps one chip visible when wide chips fill every line (Issue #1042)', () => {
|
||||
// One chip per line: the 4th lands past the 3-row cap, so the shrink loop
|
||||
// runs — and in the narrow column the input's 128px `min-w-32`
|
||||
// reservation leaves no room for any tail chip. The loop used to walk
|
||||
// `fitting` to 0 and the cell rendered with no chips at all, only
|
||||
// "Ver mais…" (the B34 e2e failure snapshot). The floor of one keeps the
|
||||
// first chip; the toggle and the input wrap to the next line.
|
||||
chipsPerLine = 1
|
||||
rowRight = 200
|
||||
inputWidth = 128
|
||||
vi.spyOn(window, 'getComputedStyle').mockImplementation((element: Element) =>
|
||||
element.tagName === 'INPUT'
|
||||
? ({ minWidth: '128px' } as CSSStyleDeclaration)
|
||||
: originalGetComputedStyle(element),
|
||||
)
|
||||
|
||||
const { container } = render(chipCell([1, 2, 3, 4]))
|
||||
|
||||
expect(chipCount(container)).toBe(1)
|
||||
expect(screen.getByText('Estado 1')).toBeTruthy()
|
||||
expect(screen.queryByText('Estado 2')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Ver mais…' }))
|
||||
expect(chipCount(container)).toBe(4)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Ver menos' }))
|
||||
expect(chipCount(container)).toBe(1)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue