mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
Merge pull request #258 from fsolla/cursor/pool-i253-48e749f5-b609
B124 — Sidebar Municípios: filtros salvos colados (sem disclosure)
This commit is contained in:
commit
33d775396e
5 changed files with 116 additions and 131 deletions
67
docs/plans/sidebar-filtros-salvos-proximidade-impl.md
Normal file
67
docs/plans/sidebar-filtros-salvos-proximidade-impl.md
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
# Impl: B124 — Sidebar Municípios: filtros salvos colados (sem disclosure)
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-08-02
|
||||
Issue: #253
|
||||
Intenção: docs/plans/sidebar-filtros-salvos-proximidade.md
|
||||
Appetite restante: ~0,5 dia eng (dentro)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** Atalhos de filtro salvo aparecem sempre visíveis, indentados e colados logo abaixo de Municípios — leem como filhos, não como seção própria.
|
||||
- **O que NÃO negociar:** sem chevron/disclosure/hover; Apagar+Desfazer intactos; `listQueryMatch` puro; sem serializador de URL no layout `(app)`; a11y (`aria-label` na lista, `aria-current` no ativo).
|
||||
- **O que reavaliar:** hipótese de editar `Sidebar.tsx` — preferir ajuste local em `MunicipalityNavSavedFilters` via `className` no `SidebarMenuSub`.
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[MunicipalityNavSavedFilters] --> B[Remove disclosure state + chevron]
|
||||
B --> C[SidebarMenuSub sempre visível]
|
||||
C --> D[Tighten vertical gap via className]
|
||||
E[municipalitySavedFilters.ts] --> F[Remove open storage API]
|
||||
G[e2e] --> H[Replace disclosure test with always-visible reload]
|
||||
```
|
||||
|
||||
**Opções consideradas:**
|
||||
|
||||
- **A:** Só esconder chevron mas manter estado open — rejeitada (viola aceite).
|
||||
- **B:** Mover sub-lista para fora do `SidebarMenuItem` de Municípios — rejeitada (quebra semântica de filho; mais churn).
|
||||
- **C (recomendada):** Remover disclosure no componente existente; apagar API de open; apertar `mt`/`py` no `SidebarMenuSub` localmente.
|
||||
|
||||
**Recomendação:** C — menor diff, dono correto, aceite coberto.
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`MunicipalityNavSavedFilters.tsx`:** remover chevron, `open` state, `useEffect` de auto-open, imports mortos; lista sempre montada; foco no delete aponta para link de Municípios; `className` com `-mt-0.5 py-0` para proximidade.
|
||||
- **`municipalitySavedFilters.ts`:** remover `OPEN_STORAGE_KEY`, `read/writeMunicipalitySavedFiltersOpen`; manter `removeItem(OPEN_STORAGE_KEY)` em `clear` para limpar legado no logout.
|
||||
- **`CampaignSidebar.tsx`:** atualizar comentário do slot children (sem disclosure).
|
||||
- **Migration:** sem migration.
|
||||
- **UI:** Impeccable B — shape (sub-lista indentada) + craft (gap) + critique (sem controles órfãos).
|
||||
|
||||
### Dados → forma
|
||||
|
||||
N/A — navegação localStorage inalterada.
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Server/utilities** — remover API de open; limpar exports mortos.
|
||||
2. **UI** — componente always-on + spacing.
|
||||
3. **Testes** — ajustar e2e disclosure → always-visible após reload; `pnpm gate:fast`; `pnpm push`.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- Não refatorar `SidebarMenuSub` globalmente.
|
||||
- Não adicionar rótulo "Filtros salvos".
|
||||
- Não truncar lista a 4 itens.
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Foco ao apagar último filtro:** resolver sucessor como link `Municípios` no mesmo `li` (já existia como fallback).
|
||||
- **Legado `localStorage` open:** `clearMunicipalitySavedFilters` no logout remove a chave antiga.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [x] Aceite de produto da intenção ainda coberto
|
||||
- [x] Invariantes AGENTS/engineering-standards
|
||||
- [x] Testes e2e ajustados (sem disclosure)
|
||||
|
|
@ -48,7 +48,7 @@ const CampaignSidebarLink = ({
|
|||
item: CampaignNavItem
|
||||
isActive: boolean
|
||||
onNavigate: () => void
|
||||
/** Row affordances that sit beside the link — B18's disclosure and sub-list. */
|
||||
/** Row affordances below the link — B18's saved-filter sub-list (B124: always on). */
|
||||
children?: ReactNode
|
||||
}) => (
|
||||
<SidebarMenuItem>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
'use client'
|
||||
|
||||
import { ChevronRightIcon, Trash2Icon } from 'lucide-react'
|
||||
import { Trash2Icon } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { usePathname, useSearchParams } from 'next/navigation'
|
||||
import { useEffect, useId, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { useMunicipalitySavedFilters } from '@/components/campaign/shared/useMunicipalitySavedFilters'
|
||||
|
|
@ -15,10 +14,8 @@ import {
|
|||
} from '@/components/ui/Sidebar'
|
||||
import { isSameListHref } from '@/lib/listQueryMatch'
|
||||
import {
|
||||
readMunicipalitySavedFiltersOpen,
|
||||
removeMunicipalitySavedFilter,
|
||||
saveMunicipalitySavedFilter,
|
||||
writeMunicipalitySavedFiltersOpen,
|
||||
type MunicipalitySavedFilter,
|
||||
} from '@/utilities/municipality/municipalitySavedFilters'
|
||||
|
||||
|
|
@ -28,18 +25,14 @@ const IGNORED_PARAMS = ['page']
|
|||
/**
|
||||
* The saved filters of `/campanha/municipios` (B18), hung under that nav item.
|
||||
*
|
||||
* Renders only the disclosure and the sub-list: the nav link above it stays the
|
||||
* one `CampaignSidebarLink` every other item uses.
|
||||
* Renders only the sub-list: the nav link above it stays the one
|
||||
* `CampaignSidebarLink` every other item uses. Always visible when entries exist
|
||||
* (B124 — no disclosure).
|
||||
*/
|
||||
export const MunicipalityNavSavedFilters = ({ onNavigate }: { onNavigate: () => void }) => {
|
||||
const savedFilters = useMunicipalitySavedFilters()
|
||||
const pathname = usePathname()
|
||||
const searchParams = useSearchParams()
|
||||
// Read once, not tracked: the store owns the list, this is only the
|
||||
// disclosure, and the toggle already writes it back.
|
||||
const [open, setOpen] = useState(readMunicipalitySavedFiltersOpen)
|
||||
const listId = useId()
|
||||
const disclosureRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
const query = searchParams.toString()
|
||||
const currentHref = query ? `${pathname}?${query}` : pathname
|
||||
|
|
@ -47,40 +40,20 @@ export const MunicipalityNavSavedFilters = ({ onNavigate }: { onNavigate: () =>
|
|||
isSameListHref(currentHref, entry.href, IGNORED_PARAMS),
|
||||
)?.href
|
||||
|
||||
// Arriving at a saved filter reveals where you are, but only on a real
|
||||
// navigation into one: opening on every mount would make the persisted
|
||||
// preference unhonorable for whoever uses saved filters most, since they
|
||||
// collapse the group, reload, and find it back. The trigger is the URL
|
||||
// changing rather than `activeHref` appearing, because the store answers
|
||||
// empty until the client has read `localStorage` — a match surfacing out of
|
||||
// hydration would otherwise read as a navigation on every single load.
|
||||
const previousHref = useRef(currentHref)
|
||||
useEffect(() => {
|
||||
const navigated = previousHref.current !== currentHref
|
||||
previousHref.current = currentHref
|
||||
if (navigated && activeHref) setOpen(true)
|
||||
}, [currentHref, activeHref])
|
||||
|
||||
// No group until there is something in it — and nothing to hydrate either,
|
||||
// since the store answers empty until the client reads `localStorage`.
|
||||
if (!savedFilters.length) return null
|
||||
|
||||
const toggle = () => {
|
||||
const next = !open
|
||||
setOpen(next)
|
||||
writeMunicipalitySavedFiltersOpen(next)
|
||||
}
|
||||
|
||||
const handleRemove = (entry: MunicipalitySavedFilter) => {
|
||||
const handleRemove = (entry: MunicipalitySavedFilter, row: HTMLLIElement | null) => {
|
||||
// The button about to vanish is the one holding focus, which would drop to
|
||||
// <body> and strand a keyboard user at the top of the page. Resolve the
|
||||
// survivor BEFORE the removal: emptying the group unmounts this whole
|
||||
// component, disclosure included, so the fallback has to be the nav link.
|
||||
const disclosure = disclosureRef.current
|
||||
// component, so the fallback has to be the nav link.
|
||||
const menuItem = row?.closest('[data-sidebar="menu-item"]')
|
||||
const successor =
|
||||
savedFilters.length > 1
|
||||
? disclosure
|
||||
: disclosure?.closest('li')?.querySelector<HTMLElement>('a')
|
||||
? ((row?.nextElementSibling ?? row?.previousElementSibling)?.querySelector<HTMLElement>(
|
||||
'[data-sidebar="menu-sub-button"]',
|
||||
) ?? menuItem?.querySelector<HTMLElement>('a'))
|
||||
: menuItem?.querySelector<HTMLElement>('a')
|
||||
|
||||
removeMunicipalitySavedFilter(entry.href)
|
||||
successor?.focus()
|
||||
|
|
@ -100,52 +73,33 @@ export const MunicipalityNavSavedFilters = ({ onNavigate }: { onNavigate: () =>
|
|||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarMenuAction
|
||||
ref={disclosureRef}
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
aria-controls={listId}
|
||||
aria-label={`${open ? 'Ocultar' : 'Mostrar'} os filtros salvos de Municípios`}
|
||||
onClick={toggle}
|
||||
className={open ? 'rotate-90' : undefined}
|
||||
>
|
||||
<ChevronRightIcon aria-hidden="true" />
|
||||
</SidebarMenuAction>
|
||||
{/* Always mounted so `aria-controls` always resolves; `hidden` wins over
|
||||
the list's own `flex` through tailwind-merge. */}
|
||||
<SidebarMenuSub
|
||||
id={listId}
|
||||
aria-label="Filtros salvos de Municípios"
|
||||
className={open ? undefined : 'hidden'}
|
||||
>
|
||||
{savedFilters.map((entry) => {
|
||||
const isActive = entry.href === activeHref
|
||||
<SidebarMenuSub aria-label="Filtros salvos de Municípios" className="-mt-0.5 py-0">
|
||||
{savedFilters.map((entry) => {
|
||||
const isActive = entry.href === activeHref
|
||||
|
||||
return (
|
||||
<SidebarMenuSubItem key={entry.href}>
|
||||
<SidebarMenuSubButton asChild isActive={isActive}>
|
||||
<Link
|
||||
href={entry.href}
|
||||
onClick={onNavigate}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
title={entry.name}
|
||||
>
|
||||
<span>{entry.name}</span>
|
||||
</Link>
|
||||
</SidebarMenuSubButton>
|
||||
<SidebarMenuAction
|
||||
type="button"
|
||||
onClick={() => handleRemove(entry)}
|
||||
aria-label={`Apagar o filtro salvo ${entry.name}`}
|
||||
className="top-1 group-focus-within/menu-sub-item:opacity-100 group-hover/menu-sub-item:opacity-100 md:opacity-0"
|
||||
return (
|
||||
<SidebarMenuSubItem key={entry.href}>
|
||||
<SidebarMenuSubButton asChild isActive={isActive}>
|
||||
<Link
|
||||
href={entry.href}
|
||||
onClick={onNavigate}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
title={entry.name}
|
||||
>
|
||||
<Trash2Icon aria-hidden="true" />
|
||||
</SidebarMenuAction>
|
||||
</SidebarMenuSubItem>
|
||||
)
|
||||
})}
|
||||
</SidebarMenuSub>
|
||||
</>
|
||||
<span>{entry.name}</span>
|
||||
</Link>
|
||||
</SidebarMenuSubButton>
|
||||
<SidebarMenuAction
|
||||
type="button"
|
||||
onClick={(event) => handleRemove(entry, event.currentTarget.closest('li'))}
|
||||
aria-label={`Apagar o filtro salvo ${entry.name}`}
|
||||
className="top-1 group-focus-within/menu-sub-item:opacity-100 group-hover/menu-sub-item:opacity-100 md:opacity-0"
|
||||
>
|
||||
<Trash2Icon aria-hidden="true" />
|
||||
</SidebarMenuAction>
|
||||
</SidebarMenuSubItem>
|
||||
)
|
||||
})}
|
||||
</SidebarMenuSub>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -124,41 +124,19 @@ export const removeMunicipalitySavedFilter = (href: string): void => {
|
|||
write(next)
|
||||
}
|
||||
|
||||
/** Legacy B18 disclosure key — removed by B124; cleared on logout. */
|
||||
const LEGACY_OPEN_STORAGE_KEY = 'teqo:campaign:municipality-saved-filters-open'
|
||||
|
||||
export const clearMunicipalitySavedFilters = (): void => {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
localStorage.removeItem(OPEN_STORAGE_KEY)
|
||||
localStorage.removeItem(LEGACY_OPEN_STORAGE_KEY)
|
||||
} catch {
|
||||
// Same reason as below: the preference is a nicety.
|
||||
// A stale preference is a nicety.
|
||||
}
|
||||
write([])
|
||||
}
|
||||
|
||||
const OPEN_STORAGE_KEY = 'teqo:campaign:municipality-saved-filters-open'
|
||||
|
||||
/**
|
||||
* The submenu's disclosure. Open by default — a shortcut nobody can see is a
|
||||
* shortcut nobody uses, and the group only exists once something is in it.
|
||||
*/
|
||||
export const readMunicipalitySavedFiltersOpen = (): boolean => {
|
||||
if (typeof window === 'undefined') return true
|
||||
try {
|
||||
return localStorage.getItem(OPEN_STORAGE_KEY) !== 'false'
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export const writeMunicipalitySavedFiltersOpen = (open: boolean): void => {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
localStorage.setItem(OPEN_STORAGE_KEY, open ? 'true' : 'false')
|
||||
} catch {
|
||||
// A remembered disclosure is a nicety; a full quota must not break the
|
||||
// toggle itself, which works from React state either way.
|
||||
}
|
||||
}
|
||||
|
||||
export const subscribeMunicipalitySavedFilters = (onChange: () => void): (() => void) => {
|
||||
if (typeof window === 'undefined') return () => {}
|
||||
const handleChange = (event: Event) => {
|
||||
|
|
|
|||
|
|
@ -79,8 +79,8 @@ test.describe('Filtros salvos de Municípios', () => {
|
|||
const remove = page.getByRole('button', { name: `Apagar o filtro salvo ${RENAMED}` })
|
||||
await remove.click()
|
||||
await expect(page.getByRole('link', { name: RENAMED })).toHaveCount(0)
|
||||
// Emptying the group unmounts the disclosure too, so focus lands on the
|
||||
// nav link rather than falling to <body>.
|
||||
// Emptying the group unmounts the sub-list, so focus lands on the nav link
|
||||
// rather than falling to <body>.
|
||||
await expect(page.getByRole('link', { name: 'Municípios', exact: true })).toBeFocused()
|
||||
|
||||
// Deleting is undoable, which is why it never asks for confirmation.
|
||||
|
|
@ -88,17 +88,14 @@ test.describe('Filtros salvos de Municípios', () => {
|
|||
await expect(page.getByRole('link', { name: RENAMED })).toBeVisible()
|
||||
})
|
||||
|
||||
test('survives a reload and hides the shortcuts behind the disclosure', async ({
|
||||
campaign,
|
||||
page,
|
||||
}) => {
|
||||
test('survives a reload with shortcuts always visible', async ({ campaign, page }) => {
|
||||
// Three loads of a heavy list route, against a dev server shared with the
|
||||
// other worker — the login redirect alone can outrun the 30 s default.
|
||||
test.slow()
|
||||
|
||||
const { fixtures } = campaign
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator', {
|
||||
name: fixtures.value('Coordenadora Disclosure'),
|
||||
name: fixtures.value('Coordenadora Sidebar'),
|
||||
})
|
||||
const password = coordinator.password
|
||||
const email = coordinator.email!
|
||||
|
|
@ -114,18 +111,7 @@ test.describe('Filtros salvos de Municípios', () => {
|
|||
|
||||
await page.reload()
|
||||
await expect(shortcut).toBeVisible(NAVIGATION)
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: 'Ocultar os filtros salvos de Municípios' })
|
||||
.click(NAVIGATION)
|
||||
await expect(shortcut).toBeHidden()
|
||||
|
||||
// The collapsed disclosure is remembered across a reload.
|
||||
await page.reload()
|
||||
await expect(page.getByRole('link', { name: SAVED_NAME })).toBeHidden(NAVIGATION)
|
||||
await page
|
||||
.getByRole('button', { name: 'Mostrar os filtros salvos de Municípios' })
|
||||
.click(NAVIGATION)
|
||||
await expect(page.getByRole('link', { name: SAVED_NAME })).toBeVisible()
|
||||
await expect(page.getByRole('list', { name: 'Filtros salvos de Municípios' })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: /filtros salvos de Municípios/i })).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue