refactor(C129): simplify — CampaignNameSubline compartilhado (a11y sr-label), guard legenda==nome, sem plumb morto, spec mais forte

This commit is contained in:
Francisco Solla 2026-08-12 12:52:29 -03:00
parent d7acb5aa48
commit 3593d84b30
10 changed files with 72 additions and 18 deletions

View file

@ -43,7 +43,8 @@ const stateDeputyContactSafeMessages = [
const stateDeputyPartySafeMessages = [STATE_DEPUTY_STAFF_MESSAGE] as const
const stateDeputyBallotNameSafeMessages = [STATE_DEPUTY_STAFF_MESSAGE] as const
/** The party and ballotName inline cells share the same refusal vocabulary. */
const stateDeputyInlineFieldSafeMessages = stateDeputyPartySafeMessages
/** Per-field Contact edit for B163 (lista + ficha). */
export const updateStateDeputyContactFormAction = async (
@ -119,7 +120,7 @@ export const updateStateDeputyBallotNameFormAction = async (
})
return { message: 'Salvo.' }
},
safeMessages: stateDeputyBallotNameSafeMessages,
safeMessages: stateDeputyInlineFieldSafeMessages,
genericMessage:
'Não foi possível salvar o nome de legenda. Verifique os dados e tente novamente.',
})

View file

@ -13,6 +13,7 @@ import {
CampaignTransitionAnchor,
} from '@/components/campaign/shared/CampaignListPending'
import { CampaignListSheetProvider } from '@/components/campaign/shared/CampaignListSheetHost'
import { CampaignNameSubline } from '@/components/campaign/shared/CampaignNameSubline'
import {
CampaignTable,
CampaignTableHead,
@ -136,10 +137,12 @@ const stateDeputyColumns = (
/>
{/* C129 — the ballot name sits OUTSIDE the name cell's click-to-edit
region (sibling, not child), so clicking it never opens the name
editor. */}
{row.ballotName ? (
<span className="truncate text-xs text-muted-foreground">{row.ballotName}</span>
) : null}
editor. A legenda identical to the real name is skipped (same
policy as `peopleNameSubline`). */}
<CampaignNameSubline
value={row.ballotName !== row.name ? row.ballotName : null}
srLabel="Nome de legenda"
/>
</div>
),
},

View file

@ -21,6 +21,7 @@ import {
CampaignTransitionAnchor,
} from '@/components/campaign/shared/CampaignListPending'
import { CampaignListSheetProvider } from '@/components/campaign/shared/CampaignListSheetHost'
import { CampaignNameSubline } from '@/components/campaign/shared/CampaignNameSubline'
import {
CampaignTable,
CampaignTableHead,
@ -193,9 +194,7 @@ const peopleColumns = ({
<span className="shrink-0 text-muted-foreground">({row.party})</span>
) : null}
</div>
{subline ? (
<span className="truncate text-xs text-muted-foreground">{subline}</span>
) : null}
<CampaignNameSubline value={subline} srLabel="Nome de legenda" />
</div>
)
},

View file

@ -0,0 +1,19 @@
/**
* The "discreto sob o nome" secondary line (C129): the same muted small text
* under a name in every surface — people table, dobradinhas list and ficha.
* The position is the visual label; an optional `srLabel` names the line for
* screen readers (the muted/small styling does not exist in a linear read).
*/
export const CampaignNameSubline = ({
value,
srLabel,
}: {
value: string | null | undefined
srLabel?: string
}) =>
value ? (
<span className="truncate text-xs text-muted-foreground">
{srLabel ? <span className="sr-only">{srLabel} </span> : null}
{value}
</span>
) : null

View file

@ -2,6 +2,7 @@
import { updateStateDeputyContactFormAction } from '@/app/(campaign)/campanha/(app)/dobradinhas/formActions'
import { CampaignInlineEditableCell } from '@/components/campaign/shared/CampaignInlineEditableCell'
import { CampaignNameSubline } from '@/components/campaign/shared/CampaignNameSubline'
import { PhonesFieldEditor } from '@/components/campaign/shared/PhonesFieldEditor'
type StateDeputyContactSectionProps = {
@ -39,8 +40,8 @@ export const StateDeputyContactSection = ({
formAction={updateStateDeputyContactFormAction}
href={`/campanha/dobradinhas/${stateDeputyId}`}
/>
{ballotName ? (
<span className="truncate text-xs text-muted-foreground">{ballotName}</span>
{ballotName !== name ? (
<CampaignNameSubline value={ballotName} srLabel="Nome de legenda" />
) : null}
</div>
</dd>

View file

@ -78,6 +78,9 @@ export type PeopleRowViewModel = {
/**
* C129 — the Name-cell subline of `/campanha/pessoas`: the dobradinha's
* "nome de legenda" (ballot name), discreet under the real name, when present.
* A ballot name identical to the real name is redundant noise — it reads as a
* duplicated line, so it is skipped (a mesa registering the full name as urn
* name is plausible data entry).
*
* C130 extends this with the base fallback — the FINAL shared rule for the
* second line is `ballotName ?? city` (legenda overrides base), the two plans
@ -86,8 +89,10 @@ export type PeopleRowViewModel = {
* here (and its unit spec), never markup. Until C130 lands, this branch keeps
* the accept "sem nome de legenda, nada muda na linha" — null when absent.
*/
export const peopleNameSubline = (person: MergedPerson | PeopleRowViewModel): string | null =>
person.ballotName
export const peopleNameSubline = (
person: Pick<MergedPerson, 'name' | 'ballotName'>,
): string | null =>
person.ballotName && person.ballotName !== person.name ? person.ballotName : null
/** The merge intermediate carries every field the list/detail row needs. */
export type MergedPerson = Omit<PeopleRowViewModel, 'assessoradoNames'> & {

View file

@ -91,7 +91,7 @@ export const loadPersonDetail = async (
depth: 1,
limit: 0,
pagination: false,
select: { contact: true, slug: true, party: true, advisors: true },
select: { contact: true, slug: true, party: true, ballotName: true, advisors: true },
user,
overrideAccess: false,
}),

View file

@ -239,13 +239,13 @@ export type StateDeputySummary = {
name: string
slug: string
party: string | null
/** C129 — the "nome de legenda" (ballot name), shown discreet under the name. */
ballotName: string | null
}
export type StateDeputyDetailViewModel = StateDeputySummary & {
email: string | null
phone: string | null
/** C129 — the "nome de legenda" (ballot name), shown discreet under the name. */
ballotName: string | null
/** Every number of the ficha, order = priority (C112) — primary first. */
phones: string[]
notes: string | null
@ -345,7 +345,7 @@ export const loadStateDeputySummaries = async (
limit: 0,
pagination: false,
sort: 'contact.name',
select: { contact: true, slug: true, party: true, ballotName: true },
select: { contact: true, slug: true, party: true },
// Intentional admin bypass: id lookups for the home-search card, no
// actor-scoping possible or needed (B52 precedent).
overrideAccess: true,
@ -359,7 +359,6 @@ export const loadStateDeputySummaries = async (
name: stateDeputyContactSummary(doc.contact).name,
slug: doc.slug,
party: doc.party ?? null,
ballotName: doc.ballotName ?? null,
},
]),
)

View file

@ -55,6 +55,15 @@ describe('updateStateDeputyBallotNameRecord (C129)', () => {
})
expect(updated.ballotName).toBeNull()
const reloaded = await payload.findByID({
collection: 'stateDeputy',
id: stateDeputy.id,
depth: 0,
select: { ballotName: true },
overrideAccess: true,
})
expect(reloaded.ballotName).toBeNull()
})
it('lets an advisor edit the ballot name of a dobradinha (staff scope, no carteira gate)', async () => {
@ -68,6 +77,15 @@ describe('updateStateDeputyBallotNameRecord (C129)', () => {
})
expect(updated.ballotName).toBe('Apelido da Mesa')
const reloaded = await payload.findByID({
collection: 'stateDeputy',
id: stateDeputy.id,
depth: 0,
select: { ballotName: true },
overrideAccess: true,
})
expect(reloaded.ballotName).toBe('Apelido da Mesa')
})
it('refuses a leader (staff gate)', async () => {

View file

@ -341,6 +341,15 @@ describe('peopleNameSubline (C129)', () => {
const maria = byContact(mergePeopleSources(source), 100)
expect(peopleNameSubline(maria)).toBeNull()
})
it('skips a ballot name identical to the real name (redundant duplicate line)', () => {
const withIdenticalBallotName = mergePeopleSources({
...source,
deputies: [{ ...source.deputies[0]!, ballotName: 'Ana Lima' }],
})
const ana = byContact(withIdenticalBallotName, 101)
expect(peopleNameSubline(ana)).toBeNull()
})
})
describe('peopleFilterFacetsFromRows', () => {