mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
feat(C236): publicar, despublicar e apagar em lote na Central de Conteúdos
A lista /campanha/comunicacao/conteudos ganha um modo de seleção opt-in que envolve os dois corpos (tabela e card list) com um estado client compartilhado, chaveado pela URL canônica (a seleção morre em página/filtro e nunca cresce para além da página visível). Com seleção ativa, a barra flutuante aplica o mesmo veredito unitário à seleção: Publicar/Despublicar agem direto com recibo honesto; Apagar sempre confirma no CampaignDeleteDialog reusado (novo caminho onConfirm aditivo), avisando os links públicos só quando a seleção carrega publicadas. O transporte é uma rota única (POST .../conteudos/lote, campaignJsonMutationRoute, teto de 50 ids) que itera server-side as ações donas: cada peça mantém a própria transação, o gate fresco e a limpeza best-effort de mídia; a falha de uma nunca reverte as demais e o recibo separa afetadas x falhas (2 de 3 peças apagadas. 1 falhou.). Sem migration, sem Consent novo, sem rota pública (S27) e sem mudar a semântica do C222. Cobertura: int do lote (papéis, dedupe, mídia, evento, tag, falha parcial, schema), unit do builder/barra/seleção e e2e HTTP. Design tier: DEGRADED (artefato de origem sem certificação frontier) — crítica do designer aplicada no escopo do item; entrega bloqueada para sign-off humano antes do PR. Related #1388
This commit is contained in:
parent
f1bc634385
commit
6940b7f1f9
27 changed files with 1653 additions and 53 deletions
1
docs/changelog/2026-09-29-c236.md
Normal file
1
docs/changelog/2026-09-29-c236.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
**Central de Conteúdos — publicar, despublicar e apagar em lote (2026-09-29):** o kill switch morava ficha a ficha e apagar era uma confirmação por linha; o uso real chega em blocos. A lista `/campanha/comunicacao/conteudos` ganhou um modo de seleção opt-in (checkbox no topo da lista, coluna `mandatory` fora do seletor de colunas, tint `primary/5` na linha) que envolve os dois corpos da lista — tabela desktop e card list mobile — com **um** estado de seleção client; a seleção é só da página visível, morre ao trocar de página/filtro (o provider é chaveado pela URL canônica) e ids que saem da página deixam de contar. Com seleção ativa a barra flutua no rodapé (`sticky`, 16px acima do FAB no mobile, recuo à direita no desktop) com `N selecionada(s)` e `Publicar`/`Despublicar`/`Apagar`: os reversíveis agem direto, o irreversível sempre confirma no `CampaignDeleteDialog` reusado via um caminho `onConfirm` aditivo — o aviso de links públicos aparece só quando a seleção carrega publicadas (singular de uma peça reusa a copy do C222). O lote é o gesto unitário repetido: `POST .../conteudos/lote` (`campaignJsonMutationRoute`, teto de 50 ids) itera server-side as ações donas, cada item na própria transação e no próprio gate, e devolve `{ affected, failures[] }` — a falha de uma peça nunca reverte as demais e o recibo inline (cena 5 do design) nunca diz "3 apagadas" com falha: `2 de 3 peças apagadas. 1 falhou.` (singular/plural no builder puro, `aria-live` no sucesso e `role="alert"` na parcial). Invariantes intocados: access fail-closed da vertical, sem routine por linha nova, sem `Consent`, sem migration, sem rota pública (S27), `ContentEvent` preservado e a semântica do C222 (slug/`publishedAt`/arquivo preservados) reusada literalmente. Cobertura: int do lote (papéis, dedupe, mídia, evento, tag, falha parcial, schema), unit do builder/barra/seleção (incl. card frame) e e2e HTTP do lote. **Design tier: DEGRADED** (artefato de origem sem certificação frontier) — a crítica do designer contra o app renderizado aplicou a adaptação do FAB/reserva de rolagem/clamp/a11y e a Issue foi flipada para `blocked` aguardando sign-off humano antes do PR (screenshots em `docs/plans/central-conteudos-acoes-em-lote-ui-critique/`).
|
||||
159
docs/plans/central-conteudos-acoes-em-lote-impl.md
Normal file
159
docs/plans/central-conteudos-acoes-em-lote-impl.md
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
# Impl: C236 — Central de Conteúdos — publicar, despublicar e apagar em lote
|
||||
|
||||
Status: aprovado (executado; entrega bloqueada aguardando sign-off do design DEGRADED)
|
||||
Atualizado em: 2026-09-29
|
||||
Issue: #1388
|
||||
Intenção: docs/plans/central-conteudos-acoes-em-lote.md
|
||||
Appetite restante: herdado (~1–1,5 dia eng) — o corte de produto (seleção só da página visível, sem job, sem agendamento, sem edição de campos) mantém o escopo dentro do appetite.
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** a assessoria seleciona várias peças **da página visível** na lista da Central (`/campanha/comunicacao/conteudos`) e aplica o **mesmo veredito unitário** — `Publicar`, `Despublicar` ou `Apagar` — num gesto, com recibo honesto (afetadas × falhas) e a lista refletindo o desfecho; a seleção morre ao trocar de página/filtro e pós-ação.
|
||||
- **O que NÃO negociar:** o lote é o gesto unitário repetido, sem regra nova — elegibilidade `processando`/`falhou` incluída; `Publicar`/`Despublicar` agem sem confirmação (reversíveis) e `Apagar` sempre confirma avisando links públicos quando há publicadas; falha parcial é honesta e não reverte as demais; audiência `communicator|coordinator|candidate` (`advisor`/`leader` negados, fail-closed), sem papel novo; sem rota pública nova (S27), sem mudar a semântica do C222 (slug canônico/`publishedAt`/arquivo preservados no despublicar, mídia privada best-effort, `ContentEvent` nunca limpo); nenhum `Consent` novo.
|
||||
- **O que reavaliar (hipóteses da intenção confirmadas no código):** o `CampaignTable` é server e não tem API de seleção (`CampaignTable.tsx:27-63,155-224`) — a seleção entra por ilha client, não por mudança de shell; a lista é renderizada 2× por breakpoint (card/tabela) — um único estado compartilhado via provider; a ação unitária já concentra semântica e transação (reuso literal, não reinterpretação).
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Page["page.tsx (RSC)<br/>conteudos"] --> Provider["ContentPieceSelectionProvider<br/>(client: modo, ids, pending, receipt)"]
|
||||
Provider --> Mode["ContentPieceSelectionModeControl"]
|
||||
Provider --> Table["ContentPieceTable (RSC)<br/>coluna seleção oculta fora do modo"]
|
||||
Provider --> Cards["ContentPieceCardList (RSC)"]
|
||||
Table --> Boxes["ContentPieceRowCheckbox /<br/>ContentPieceSelectAllCheckbox"]
|
||||
Cards --> Frame["ContentPieceCardSelectionFrame"]
|
||||
Provider --> Bar["ContentPieceSelectionBar"]
|
||||
Bar -->|Publicar / Despublicar| BatchRoute["POST .../conteudos/lote<br/>campaignJsonMutationRoute"]
|
||||
Bar --> DeleteDialog["CampaignDeleteDialog<br/>(confirm = batch)"]
|
||||
DeleteDialog -->|Apagar| BatchRoute
|
||||
BatchRoute --> BatchAction["applyContentPieceBatchActionForActor<br/>loop por item, transação por item"]
|
||||
BatchAction --> PublishAction["setContentPiecePublishedForActor"]
|
||||
BatchAction --> DeleteAction["deleteContentPieceForActor"]
|
||||
BatchAction --> Outcome["outcome { action, affected, failures }"]
|
||||
Outcome --> Receipt["ContentPieceBatchReceipt<br/>(inline, status/alert)"]
|
||||
Outcome --> Refresh["clear() + router.refresh()"]
|
||||
```
|
||||
|
||||
**Opções consideradas:** A | B | C
|
||||
**Recomendação:** **B** — uma rota única `POST .../conteudos/lote` que itera server-side reusando as ações unitárias, cada item na própria transação, e devolve um recibo estruturado. Reuso literal da semântica (slug/`publishedAt`/arquivo/mídia/tag), teto de lote fixado em schema (max 50, precedente `contentPieceStatusRequestSchema`), recibo e falha parcial testáveis no int, transporte único pelo wrapper `campaignJsonMutationRoute`.
|
||||
**Rejeitadas:** **A** (cliente dispara N fetches unitários) porque espalha no cliente o laço, o recibo e a agregação de falhas, faz N round-trips e não fixa o teto do conjunto em nenhum contrato; **C** (transação única do lote) porque a intenção fixa falha parcial honesta — uma falha não pode reverter as demais —, prolongaria a transação e mudaria a semântica pós-commit da mídia privada.
|
||||
|
||||
### Decisões de engenharia (caras de reverter)
|
||||
|
||||
#### 1. Transporte do lote
|
||||
|
||||
**Opções:** A) o cliente dispara N fetches às rotas unitárias existentes | B) rota nova única `POST .../conteudos/lote` com `{ action, contentPieceIds }` iterando server-side o reuso das ações unitárias | C) transação única para o lote.
|
||||
**Recomendação:** **B** — a rota entra por `campaignJsonMutationRoute` (guard de convenção em `tests/unit/codebaseConventions.unit.spec.ts:251`), o schema fixa o teto (`.min(1).max(50)`), e a action `applyContentPieceBatchActionForActor` chama `setContentPiecePublishedForActor` / `deleteContentPieceForActor` **uma vez por id** — cada chamada mantém sua própria transação (`withPayloadTransaction`), o gate fresco e a limpeza de mídia pós-commit (`contentPieces.ts:163-189,239-278`). O recibo (`affected` + `failures[]`) nasce no domínio e é testável no int; o cliente só traduz para os literais do design.
|
||||
**Alternativas rejeitadas:** A porque duplica no cliente o laço/agregação, multiplica round-trips (até 25) e não tem contrato de teto nem recibo de domínio; C porque contraria a decisão de produto de falha parcial honesta, cria transação longa e conflita com o `onPayloadTransactionCommit` da mídia por item.
|
||||
**Detalhe de execução:** o loop é sequencial (determinístico, gentil com o banco; 25 itens máx. da página). Se a latência do lote doer (medida no e2e), o gatilho de revisitação é paralelizar com `Promise.allSettled` mantendo o mesmo outcome — barato e local.
|
||||
|
||||
#### 2. Estado de seleção compartilhado (tabela + card list)
|
||||
|
||||
**Opções:** A) um client provider via `children` (RSC preservado), com estado de ids, modo, pending e recibo, resetado por `key` da URL canônica da lista | B) Context API com row registry (cada linha registra/desregistra no provider) | C) estado no servidor via URL (`?sel=1,2,3`).
|
||||
**Recomendação:** **A** — `ContentPieceSelectionProvider` (client) recebe uma projeção serializável das rows (`{ id, status, publicPath }`) e envolve os dois corpos renderizados pela RSC (`ContentPieceCardList` e `ContentPieceTable`), que continuam server; as ilhas de checkbox consomem o contexto. O provider é chaveado pela URL canônica da lista (`buildContentPieceListHref(data.state, data.state.page)`), então trocar página/filtro **remonta** e zera seleção, modo e recibo. Pós-ação, o provider chama `clear()` e `router.refresh()`. O poll de status (`ContentPieceStatusRefresher.tsx:48`) refresca sem mudar a key — a seleção sobrevive a uma renovação de status, que é o comportamento correto (os ids seguem válidos).
|
||||
**Alternativas rejeitadas:** B porque é maquinaria de sincronização sem necessidade (as rows já chegam como prop) e dificulta "selecionar todas" e o aviso de publicadas; C porque contraria "a seleção morre ao navegar", polui o contrato da URL canônica com ordenação/limites e transforma cada checkbox numa navegação com re-render do servidor.
|
||||
|
||||
#### 3. Coluna de seleção no `CampaignTable` server
|
||||
|
||||
**Opções:** A) coluna definida sempre em `columns` com `mandatory: true`, head/cell como ilhas client que leem o contexto, e a coluna nascendo oculta por CSS (`data-selection-mode` no root do provider + variantes `group-data-[selection-mode=true]:table-cell`); a lista do column picker **não** ganha a coluna | B) checkbox permanente, sem modo | C) dar API de seleção ao `CampaignTable` (ou torná-lo client).
|
||||
**Recomendação:** **A** — o shell da tabela não muda (`CampaignTable.tsx` segue server e data-driven); `mandatory: true` protege contra cookie antigo (`campaignColumnVisibility.ts:184-192`) e a coluna não entra no picker em `page.tsx:60-66` porque é chrome do modo de seleção, não dado. O tint da linha selecionada usa `rowClassName: 'has-[[data-selection-state=selected]]:bg-primary/5'` (o `has-[...]` já é usado em `ui/Table.tsx:57,67` e `ui/field.tsx`), e a ilha marca `data-selection-state` no seu root.
|
||||
**Alternativas rejeitadas:** B porque contraria o design aprovado (repouso sem ruído) e a questão de produto A da intenção; C porque acopla um concern de uma superfície ao sistema de tabela compartilhado — a seleção é local da Central, não do shell (depth errado).
|
||||
|
||||
#### 4. Barra de ações e confirmação de apagar
|
||||
|
||||
**Opções:** A) barra client dentro do provider (`sticky bottom-4 z-20`, classes do artefato) e confirmação reusando `CampaignDeleteDialog` estendido de forma **aditiva** com um caminho `onConfirm`/`onSuccess` (union: `endpoint` XOR `onConfirm`) | B) criar um `ContentPieceBatchDeleteDialog` irmão, duplicando o chrome | C) montar o `AlertDialog` inline na barra.
|
||||
**Recomendação:** **A** — a barra é a única dona do gesto (contagem, botões, pending, recibo) e o diálogo continua com **um só dono de chrome/estado** (`CampaignDeleteDialog.tsx:54-127`: ícone circular destrutivo, rodapé `muted/50`, `Cancelar` outline, `Apagar` vermelho cheio, erro dentro do diálogo, spinner/disabled). A extensão não toca os 3 call sites atuais (cut, recording, peça): `onConfirm: () => Promise<{ ok: true } | { ok: false; message: string }>` e `onSuccess?: () => void`; no caminho novo, o machine chama `onConfirm()` em vez do DELETE por endpoint e, no sucesso, mantém o `router.refresh()` e chama `onSuccess`. A barra computa título/descrição: `1` selecionada reusa o C222 (`Apagar esta peça?` + link nomeado quando publicado); `N > 1` usa `Apagar N peças selecionadas?` com o aviso de publicadas × só "não pode ser desfeita".
|
||||
**Alternativas rejeitadas:** B porque duplica um machine extraído justamente para não ter a 4ª cópia (C183→C199→C222); C porque recopia chrome, submitting/erro/disabled que o machine já owns.
|
||||
|
||||
#### 5. Recibo e estado de erro
|
||||
|
||||
**Opções:** A) banner inline no topo da lista (ilha client no provider; `role="status"`/`aria-live` no sucesso, `role="alert"` na falha parcial) | B) toast sonner (`useCampaignFormSuccessToast`, layout `(campaign)/layout.tsx:52`) | C) mensagem dentro da barra.
|
||||
**Recomendação:** **A** — o design do gate (cena 5) fixa o recibo inline no topo da lista, nunca sobre ela; o precedente sonner é de form action, não de lista, e um `role="alert"` de falha parcial precisa ficar no contexto da lista, não num canto transitório. Sucesso some sozinho (~6 s) ou no X; falha parcial/erro fica até fechar ou até a próxima ação. Os literais saem de um builder puro (singular/plural seguindo `TourComposerForm.tsx:180` e o literal do plano): `3 peças publicadas`, `1 peça despublicada`, `2 de 3 peças apagadas. 1 falhou.`; a UI não renderiza mensagem por peça (fora do design), mas o outcome carrega `failures[]` para teste e diagnóstico. Erro de rota no `Publicar`/`Despublicar` vira banner de erro e **mantém** a seleção (nada mudou); no `Apagar` o erro fica dentro do diálogo (como no C222).
|
||||
**Alternativas rejeitadas:** B porque o design fixa o recibo inline e o toast some sozinho — a falha parcial não pode ser silenciosa; C porque a barra sai de cena quando a seleção zera e o recibo precisa sobreviver à limpeza da seleção.
|
||||
|
||||
#### 6. Migração, access e Consent
|
||||
|
||||
**Opções:** A) sem migration, reusando access existente e o gate fresco por ação | B) migration nova | C) mecanismo de consentimento/auditoria novo.
|
||||
**Recomendação:** **A** — nenhuma coleção/campo muda (o lote só chama ações existentes), nenhuma `Consent` nova, nenhuma role nova. `applyContentPieceBatchActionForActor` repete o gate fresco (`canReadCommunicationCatalog`) **antes** do loop — role/auth negados falham a requisição inteira (400/401), não viram falha parcial — e cada item reusa a ação que re-checa o gate e termina na collection access (`canUpdateContentPiece`/`canDeleteContentPiece`, `utilities/access/contentPieces.ts:40-58`) como barreira final.
|
||||
**Alternativas rejeitadas:** B por desnecessária (nenhum schema); C por sair do escopo e do invariante "nada de Consent novo".
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`ContentPieceSelectionProvider`** (`src/components/campaign/content/ContentPieceSelectionProvider.tsx`, novo): contexto client com `selectionMode`, `selectedIds` (`number[]` + `Set` memoizado, precedente `TourComposerForm.tsx:137-141`), `pendingAction`, `receipt`; ações `enter/exit/toggle/toggleAll/clear/applyBatch`; `resetKey` por `key`; transporta o lote com `postCampaignJson` (`lib/campaignJsonRequest.ts:12-26`) e orquestra `clear()` + `router.refresh()`.
|
||||
- **`ContentPieceSelectionControls`** (`.../ContentPieceSelectionControls.tsx`, novo): ilhas `ContentPieceSelectionModeControl` (botão `Selecionar` × badge `Modo de seleção` + `Cancelar seleção`), `ContentPieceRowCheckbox` (tabela, com `data-selection-state`), `ContentPieceSelectAllCheckbox` (`indeterminate`), `ContentPieceCardSelectionFrame` (envelope client do card mobile com checkbox 44 px + `ring-1 ring-primary/25`; corpo do card continua RSC via `children`).
|
||||
- **`ContentPieceSelectionBar`** (`.../ContentPieceSelectionBar.tsx`, novo): barra com `N selecionada(s)`, `Publicar`/`Despublicar`/`Apagar`, pending (`aria-busy`), consumindo o provider; monta o `CampaignDeleteDialog` do batch.
|
||||
- **`ContentPieceBatchReceipt`** (`.../ContentPieceBatchReceipt.tsx`, novo): banner inline do recibo (cena 5 do artefato).
|
||||
- **`CampaignDeleteDialog`** (`src/components/campaign/shared/CampaignDeleteDialog.tsx`): extensão aditiva do union `endpoint` XOR `onConfirm` + `onSuccess` (decisão 4); call sites atuais intocados.
|
||||
- **`ContentPieceTable`** (`src/components/campaign/content/ContentPieceTable.tsx`): coluna `selection` `mandatory` com head/cell ilhas, CSS de modo, `rowClassName` de tint; nenhuma mudança no `CampaignTable`.
|
||||
- **`ContentPieceCardList`** (`src/components/campaign/content/ContentPieceCardList.tsx`): card envolvido pelo frame client de seleção (sem mudar o conteúdo RSC).
|
||||
- **`page.tsx`** (`src/app/(campaign)/campanha/(app)/comunicacao/conteudos/page.tsx`): provider em volta dos dois corpos + mode control + recibo + barra; projeção `selectionRows` e `resetKey` calculados da `data.state`; coluna `selection` fora do `toCampaignColumnPickerColumns`.
|
||||
- **`contentPieceBatch`** (`src/lib/contentPieceBatch.ts`, novo): vocabulário `publicar|despublicar|apagar`, labels e builder puro do recibo (singular/plural/parcial).
|
||||
- **Schema** (`src/lib/schemas/contentPiece.ts`): `contentPieceBatchRequestSchema` (`action` enum + `contentPieceIds` `.min(1).max(50)`, precedente `:133-135`).
|
||||
- **Action** (`src/app/(campaign)/campanha/actions/contentPieces.ts`): `applyContentPieceBatchActionForActor` com gate fresco, loop sequencial, `mapCampaignFormActionError` + `CONTENT_PIECE_SAFE_MESSAGES` para mascarar falha por item.
|
||||
- **Rota** (`src/app/(campaign)/campanha/(app)/comunicacao/conteudos/lote/route.ts`, novo): `POST = campaignJsonMutationRoute(...)` devolvendo `{ status: 'success', outcome }`; convive com `[id]` como `enviar`/`status`/`link` já convivem. Sem rota DELETE nova.
|
||||
- **Contratos/wire** (`.../conteudos/types.ts`): `ContentPieceBatchResponse`/`ContentPieceBatchOutcome`/`ContentPieceBatchFailure`; **paths** (`src/lib/campaignPaths.ts`): `CAMPAIGN_CONTENT_PIECE_BATCH_HREF`.
|
||||
- **Migration:** N/A — nenhuma coleção/campo/índice muda; nenhuma migration criada (skill `payload-migrations` não é acionada).
|
||||
- **Access / Consent:** sem mudança de access; sem `Consent` novo; fail-closed preservado.
|
||||
- **UI:** Impeccable **B** — encaixe na lista existente. Port **classe-a-classe** do artefato DEGRADED `docs/plans/central-conteudos-acoes-em-lote-ui-design.html`; shells reusados: `CampaignPageShell`, `CampaignListPendingBoundary/Results`, `CampaignListFooter`, `CampaignTable`/`CampaignTableHead`, `AlertDialog`, `Button`, `Checkbox`, `Spinner`, badges. Classes-chave a preservar: barra `bar-shadow flex items-center justify-between gap-4 rounded-xl border border-border bg-card px-3 py-2.5` (mobile `p-3` com `grid grid-cols-3 gap-2`); contador `text-sm font-medium tabular-nums`; `Publicar` `bg-primary text-primary-foreground`, `Despublicar` outline `hover:bg-muted`, `Apagar` `bg-destructive/10 text-destructive hover:bg-destructive/20`; checkbox `size-4 rounded-[4px]` com hit-area e alvo `size-9` (tabela)/`size-11` (card); linha `bg-primary/5`; card `ring-1 ring-primary/25`; badge do modo `bg-primary/10 text-primary`; recibos `rounded-lg border` com check verde só no ícone e `border-destructive/40` na falha; `min-h-11` em todo alvo; `motion-reduce:animate-none` no spinner. **Sign-off humano obrigatório no PR** (design DEGRADED).
|
||||
- **Testes:** int em `tests/int/contentPiece.int.spec.ts` (novo describe de lote), unit novos `tests/unit/contentPieceBatchReceipt.unit.spec.ts` e `tests/unit/contentPieceSelection.unit.spec.tsx` (template `contentPieceDeleteDialog.unit.spec.tsx`: mock de `next/navigation` + fetch), e2e no describe de content pieces de `tests/e2e/campaignSpeechAcervo.e2e.spec.ts`.
|
||||
|
||||
### Dados → forma (se aplicável)
|
||||
|
||||
- **Não se aplica** — a intenção fixa "não apresento dados": nenhum painel, métrica, dashboard ou telemetria. A forma do recibo (banner inline com contagens do outcome) é a da cena 5 do design; o número é o resultado da própria ação, não leitura agregada nova.
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Tracer / server (schema → action → rota)** — quota ~0,5 dia. `contentPieceBatchRequestSchema` + `contentPieceBatch` (builder do recibo) + `applyContentPieceBatchActionForActor` + `lote/route.ts` + `CAMPAIGN_CONTENT_PIECE_BATCH_HREF` + tipos do wire; int cobrindo matriz de papéis (communicator/coordinator/candidate/admin × advisor/leader), publicar/despublicar em lote preservando slug/`publishedAt`/arquivo, apagar em lote derrubando mídia (best-effort) e mantendo `ContentEvent`, falha parcial honesta (`affected` + `failures` com `NOT_FOUND`), teto/limite do schema e bust da tag `contentPieces`; e2e de rota (HTTP) com sucesso, falha parcial e negado.
|
||||
2. **UI** — quota ~0,5–0,75 dia. Provider + mode control + ilhas de checkbox (tabela e card) + coluna `mandatory` com CSS de modo + tint + barra + extensão aditiva do `CampaignDeleteDialog` + recibo; unit de seleção (modo, contagem `1 selecionada`/`N selecionadas`, POST com `{ action, contentPieceIds }`, limpeza pós-ação, recibo de sucesso e parcial, diálogo plural/singular com aviso de publicadas, erro de rota mantendo seleção) e unit do builder do recibo; port classe-a-classe revisado contra o artefato.
|
||||
3. **Gates** — `pnpm gate:fast`; push via `pnpm push` (o CI PR roda o cascade curado; o deploy roda a suíte full). Sign-off humano do design DEGRADED no PR.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- Seleção cross-page/"todos os filtrados", dedupe, contagem global — corte explícito da intenção (só a página visível; 25).
|
||||
- Seleção persistente em `localStorage`/cookie ou modo de seleção sobrevivendo à navegação — a key da URL zera tudo de propósito.
|
||||
- Marcar as peças que falharam como "retry da seleção", progresso por item, fila/retomada/cancelamento — o recibo é uma frase, não um painel.
|
||||
- Toast e banner ao mesmo tempo; auditoria/telemetria de quem agiu; undo/lixeira.
|
||||
- Otimismo nas rows (atualizar a linha antes do refresh) — o refresh do RSC é a verdade; o estado de envio cobre o feedback.
|
||||
- "Selecionar todas" também no card list mobile (o design não mostra; a barra mobile não ganha header de seleção).
|
||||
- Adicionar API de seleção ao `CampaignTable` ou converter a tabela em client — o shell fica intocado.
|
||||
- Migrations ("já que mexe na lista, ajusta X"), novos campos em `contentPiece`, `Consent` novo, mudar o gesto unitário (slug/`publishedAt`/arquivo/mídia/`ContentEvent`) ou as rotas públicas S27.
|
||||
- Editar `docs/plans/central-conteudos-acoes-em-lote.md` (intenção) ou o design HTML.
|
||||
- **Adiado com gatilho (triage /simplify):** a frase singular de apagar ("O link público `<path>` deixa de funcionar… Esta ação não pode ser desfeita.") é paralela entre a barra de lote e o `ContentPieceDeleteDialog` (C222) — o dono único da copy sai quando houver um 3º call site ou quando a copy/design do aviso mudar (DRY de 2 sites não vira item).
|
||||
- **Explicitamente fora (triage /simplify):** sombra da barra como valor arbitrário (port classe-a-classe do `bar-shadow` do artefato; tokenização só se a regra do DESIGN.md §7 for aprovada no sign-off do PR); ids obsoletos no estado do provider (filtrados por `visibleSelectedIds` em todo consumidor); esconder o entry "Selecionar" com lista vazia já resolvido (`page.tsx` condiciona a `data.rows.length > 0`).
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Estado client sobrevive a refresh/navegação** — a key do provider é a URL canônica da lista (`page`+filtros): navegação remonta e zera; o refresh pós-ação/poll não muda a key (seleção pós-ação é limpa por `clear()`; renovação de status preserva os ids válidos). Risco residual: o poll renovar rows durante a seleção — aceitável e coberto por testar que a seleção sobrevive ao refresh de mesma key.
|
||||
- **Dois corpos (tabela + card) com um estado** — provider único envolvendo os dois; cada corpo renderiza sua ilha; teste unitário garante que marcar no card reflete na barra e no select-all (e vice-versa) — na prática o mesmo contexto.
|
||||
- **`CampaignTable` server sem API de seleção** — coluna estática + CSS `data-selection-mode` + ilhas; `mandatory` contra cookie; `ui/Table` já remove `pr-0` quando há `role=checkbox`. Se um dia o shell ganhar seleção nativa, revisitar (barato).
|
||||
- **Falha parcial silenciosa** — o outcome sempre separa `affected` de `failures` e o recibo nunca diz "N apagadas" com falha; o literal parcial é o do plano (`2 de 3 peças apagadas. 1 falhou.`); `role="alert"` na parcial.
|
||||
- **Mídia privada best-effort / `ContentEvent` intocado** — o lote não toca nesses caminhos: reusa `deleteContentPieceForActor` (transação + `onPayloadTransactionCommit` + `overrideAccess` intencional só da mídia) e não faz nenhuma escrita em `contentEvent`.
|
||||
- **Latência de até 25 itens em loop sequencial** — aceitável para um gesto de mesa com teto da página; gatilho de revisitação: se o e2e/int acusar latência, paralelizar com `Promise.allSettled` mantendo o outcome.
|
||||
- **Convenção de rotas** — POST novo usa `campaignJsonMutationRoute` (guard `codebaseConventions:251`); nenhuma rota DELETE nova (guard `:271` fica satisfeito por não existir).
|
||||
- **Sessão/role expirada no meio do lote** — gate e auth são checados antes do loop; negado vira 400/401 inteiro, não falha parcial disfarçada.
|
||||
- **Dupla renderização a11y** — o corpo oculto por breakpoint sai da árvore de acessibilidade (`display:none`); labels dos checkboxes incluem o título da peça (`Selecionar <título>`), select-all `Selecionar todas as peças desta página`.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto da intenção ainda coberto: seleção por página nos dois corpos; barra só com seleção; `Publicar`/`Despublicar` sem confirmação e `Apagar` sempre confirmando (com aviso de publicadas); recibo com afetadas × falhas e sem sucesso parcial silencioso; elegibilidade idêntica ao unitário; seleção morre em página/filtro e pós-ação; audiência `communicator|coordinator|candidate` fail-closed; nada de rota pública.
|
||||
- [ ] Invariantes AGENTS/engineering-standards: Local API sempre com `user` + `overrideAccess: false` (herdado das ações reusadas); escritas multi-collection transacionais com `req` (herdado); copy pt-BR / identificadores em inglês; sem `Consent` novo; sem tocar S27; sem editar migration existente nem criar migration (N/A); "edite o dono, não crie o gêmeo" (diálogo e shells reusados).
|
||||
- [ ] Testes de domínio previstos (unit/int) onde access/write paths mudam: int do lote (papéis, transações por item, mídia, `ContentEvent`, tag, falha parcial, schema), unit da barra/seleção (transporte, limpeza, recibos, diálogo) e unit do builder puro; e2e de rota no arquivo existente.
|
||||
- [ ] Gates: `pnpm gate:fast` verde; push via `pnpm push`; sign-off humano do design DEGRADED registrado no PR.
|
||||
|
||||
## Self-score (decision-quality, gate ≥4)
|
||||
|
||||
**5/5** — (1) decisões caras com Opções A|B|C e rejeitadas explícitas (transporte, estado, coluna, barra/diálogo, recibo, migration/access); (2) cabe no appetite herdado (~1–1,5 dia; server ~0,5, UI ~0,5–0,75, sem rabbit hole); (3) rabbit holes de engenharia nomeados (cross-page, persistência, progresso, toast duplo, API de seleção no shell); (4) depth check: reusa `CampaignDeleteDialog`, `CampaignTable`, `Checkbox`, `postCampaignJson`, `campaignJsonMutationRoute`, `withPayloadTransaction`/ações donas, `CampaignListResults` e o builder puro centraliza o recibo; (5) outcome da intenção intocado — nenhuma semântica unitária, access ou rota pública mudou.
|
||||
|
||||
## Crítica de design (trigger c, 2026-09-29)
|
||||
|
||||
**Veredito: DEGRADED — não certificado.** O designer frontier (`openai/gpt-5.6-sol`) rodou a crítica contra o app renderizado (screenshots 390/1280 em `docs/plans/central-conteudos-acoes-em-lote-ui-critique/`), mas não certifica: o artefato de origem é DEGRADED e exige sign-off humano. Ajustes do designer:
|
||||
|
||||
1. **FAB × barra (trigger b, aplicado):** `ContentPieceSelectionBar` → `sticky bottom-32 z-20 mt-4 md:bottom-4 md:mr-16` (no mobile a barra sobe acima do `CampaignQuickActionsFab`; no desktop recua à direita).
|
||||
2. **Reserva de rolagem (aplicado):** `CampaignListResults` da lista ganha `group-data-[selection-mode=true]:pb-64 md:group-data-[selection-mode=true]:pb-28` — durante a seleção a barra não oculta o foco/último item.
|
||||
3. **Diálogo translúcido/overlay (deferido ao sign-off):** tocar `AlertDialog` (overlay `bg-black/10` + blur, `max-w-xs` mobile, `leading` da descrição) muda o chrome de **todas** as superfícies do app (corte, gravação, C222) — decisão humana, registrada no comentário da Issue.
|
||||
4. **Largura do diálogo de lote (aplicado, escopado):** novo `contentClassName` aditivo no `CampaignDeleteDialog`; a barra passa `max-w-sm` (358px em 390) sem tocar os call sites existentes.
|
||||
5. **Clamp de títulos (aplicado):** `line-clamp-2 break-words` no título da tabela e do card.
|
||||
6. **Recibo a11y (aplicado):** sucesso com `aria-live="polite" aria-atomic="true"`; fechar com `focus-visible:ring-3 focus-visible:ring-ring/50`.
|
||||
7. **Semântica aprovada preservada:** rascunhos sem aviso de links; seleção com publicadas com o aviso — inalterado.
|
||||
|
||||
**Estado da entrega:** implementação, testes (int/unit/e2e) e `pnpm gate:fast` verdes. Com `--auto` não há humano para o sign-off, então o fluxo **não abre PR**: Issue comentada e flipada para `blocked`. A proposta de evolução do `DESIGN.md` §7 (modo de seleção de lista) continua proposta, dependente do sign-off.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 129 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 149 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 234 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 57 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
|
|
@ -0,0 +1,32 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
|
||||
import { applyContentPieceBatchActionForActor } from '@/app/(campaign)/campanha/actions/contentPieces'
|
||||
import {
|
||||
CONTENT_PIECE_FORBIDDEN_MESSAGE,
|
||||
CONTENT_PIECE_GENERIC_ERROR_MESSAGE,
|
||||
contentPieceBatchRequestSchema,
|
||||
} from '@/lib/schemas/contentPiece'
|
||||
import { campaignJsonMutationRoute } from '@/utilities/campaignJsonMutationRoute'
|
||||
|
||||
import type { ContentPieceBatchResponse } from '../types'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
/**
|
||||
* C236 — the batch gateway of the Central list: one request carries the verb
|
||||
* and the ids of the visible page; the action repeats the unit gesture per
|
||||
* piece and answers the honest outcome (affected × failures). The loop belongs
|
||||
* to the server, next to the semantics it reuses — the client only renders the
|
||||
* receipt.
|
||||
*/
|
||||
export const POST = campaignJsonMutationRoute(
|
||||
{
|
||||
bodySchema: contentPieceBatchRequestSchema,
|
||||
safeMessages: [CONTENT_PIECE_FORBIDDEN_MESSAGE],
|
||||
genericMessage: CONTENT_PIECE_GENERIC_ERROR_MESSAGE,
|
||||
},
|
||||
async (body) => {
|
||||
const outcome = await applyContentPieceBatchActionForActor(body)
|
||||
return NextResponse.json<ContentPieceBatchResponse>({ status: 'success', outcome })
|
||||
},
|
||||
)
|
||||
|
|
@ -4,8 +4,12 @@ import { redirect } from 'next/navigation'
|
|||
import { getPayload } from 'payload'
|
||||
|
||||
import { AddContentPieceLinkDialog } from '@/components/campaign/content/AddContentPieceLinkDialog'
|
||||
import { ContentPieceBatchReceipt } from '@/components/campaign/content/ContentPieceBatchReceipt'
|
||||
import { ContentPieceCardList } from '@/components/campaign/content/ContentPieceCardList'
|
||||
import { ContentPieceFilters } from '@/components/campaign/content/ContentPieceFilters'
|
||||
import { ContentPieceSelectionBar } from '@/components/campaign/content/ContentPieceSelectionBar'
|
||||
import { ContentPieceSelectionModeControl } from '@/components/campaign/content/ContentPieceSelectionControls'
|
||||
import { ContentPieceSelectionProvider } from '@/components/campaign/content/ContentPieceSelectionProvider'
|
||||
import { ContentPieceStatusRefresher } from '@/components/campaign/content/ContentPieceStatusRefresher'
|
||||
import { ContentPieceTable } from '@/components/campaign/content/ContentPieceTable'
|
||||
import { ContentPieceUploadDialog } from '@/components/campaign/content/ContentPieceUploadDialog'
|
||||
|
|
@ -20,6 +24,7 @@ import {
|
|||
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
|
||||
import { toCampaignColumnPickerColumns } from '@/lib/campaignColumnVisibility'
|
||||
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
|
||||
import type { ContentPieceSelectionRow } from '@/lib/contentPieceBatch'
|
||||
import { CONTENT_PIECE_PROFILE_IMPORT_UNAVAILABLE_MESSAGE } from '@/lib/schemas/contentPiece'
|
||||
import { readCampaignColumnVisibility } from '@/utilities/campaignColumnVisibilityCookie'
|
||||
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
|
||||
|
|
@ -57,6 +62,19 @@ export default async function ContentPiecesPage({ searchParams }: ContentPiecesP
|
|||
processingStatus: row.processingStatus,
|
||||
}))
|
||||
|
||||
// C236 — the serializable projection the client selection provider needs:
|
||||
// the delete warning (status + public path) and the ids of "selecionar
|
||||
// todas" (the visible page). The checkbox labels come from the row islands,
|
||||
// which already render the piece title.
|
||||
const selectionRows: ContentPieceSelectionRow[] = data.rows.map((row) => ({
|
||||
id: row.id,
|
||||
status: row.status,
|
||||
publicPath: row.publicPath,
|
||||
}))
|
||||
// The selection is page-scoped: the canonical list URL is the provider's key,
|
||||
// so navigating to another page/filter remounts it and forgets the selection.
|
||||
const selectionResetKey = buildContentPieceListHref(data.state, data.state.page)
|
||||
|
||||
const columns = toCampaignColumnPickerColumns([
|
||||
{ id: 'piece', label: 'Peça', mandatory: true },
|
||||
{ id: 'processing', label: 'Processamento' },
|
||||
|
|
@ -142,24 +160,31 @@ export default async function ContentPiecesPage({ searchParams }: ContentPiecesP
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
<CampaignListResults>
|
||||
<ContentPieceCardList rows={data.rows} empty={emptyState} />
|
||||
<ContentPieceTable
|
||||
rows={data.rows}
|
||||
columnVisibility={columnVisibility}
|
||||
empty={emptyState}
|
||||
/>
|
||||
{data.rows.length > 0 ? (
|
||||
<CampaignListFooter
|
||||
totalDocs={data.totalDocs}
|
||||
singular="peça"
|
||||
plural="peças"
|
||||
page={data.state.page}
|
||||
totalPages={data.totalPages}
|
||||
hrefForPage={(page) => buildContentPieceListHref(data.state, page)}
|
||||
<ContentPieceSelectionProvider key={selectionResetKey} rows={selectionRows}>
|
||||
{data.rows.length > 0 ? <ContentPieceSelectionModeControl /> : null}
|
||||
<ContentPieceBatchReceipt />
|
||||
|
||||
<CampaignListResults className="group-data-[selection-mode=true]:pb-64 md:group-data-[selection-mode=true]:pb-28">
|
||||
<ContentPieceCardList rows={data.rows} empty={emptyState} />
|
||||
<ContentPieceTable
|
||||
rows={data.rows}
|
||||
columnVisibility={columnVisibility}
|
||||
empty={emptyState}
|
||||
/>
|
||||
) : null}
|
||||
</CampaignListResults>
|
||||
{data.rows.length > 0 ? (
|
||||
<CampaignListFooter
|
||||
totalDocs={data.totalDocs}
|
||||
singular="peça"
|
||||
plural="peças"
|
||||
page={data.state.page}
|
||||
totalPages={data.totalPages}
|
||||
hrefForPage={(page) => buildContentPieceListHref(data.state, page)}
|
||||
/>
|
||||
) : null}
|
||||
</CampaignListResults>
|
||||
|
||||
<ContentPieceSelectionBar />
|
||||
</ContentPieceSelectionProvider>
|
||||
</CampaignListPendingBoundary>
|
||||
</CampaignPageShell>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { ContentPieceProfileCandidate, ContentPieceViewModel } from '@/lib/contentPiece'
|
||||
import type { ContentPieceBatchOutcome } from '@/lib/contentPieceBatch'
|
||||
|
||||
/** Wire contract of `POST .../conteudos/enviar` (C211) — raw-body upload. */
|
||||
export type ContentPieceUploadResponse =
|
||||
|
|
@ -51,3 +52,8 @@ export type ContentPieceAttachResponse =
|
|||
export type ContentPieceDeleteResponse =
|
||||
| { status: 'success'; deleted: true }
|
||||
| { status: 'error'; message: string }
|
||||
|
||||
/** Wire contract of `POST .../conteudos/lote` (C236) — the batch verdict. */
|
||||
export type ContentPieceBatchResponse =
|
||||
| { status: 'success'; outcome: ContentPieceBatchOutcome }
|
||||
| { status: 'error'; message: string }
|
||||
|
|
|
|||
|
|
@ -12,13 +12,17 @@ import {
|
|||
type ContentPieceCuratedField,
|
||||
type ContentPieceViewModel,
|
||||
} from '@/lib/contentPiece'
|
||||
import type { ContentPieceBatchOutcome } from '@/lib/contentPieceBatch'
|
||||
import { normalizeContentPiecePublicFigures } from '@/lib/publicFigureCatalog'
|
||||
import {
|
||||
CONTENT_PIECE_FORBIDDEN_MESSAGE,
|
||||
CONTENT_PIECE_GENERIC_ERROR_MESSAGE,
|
||||
CONTENT_PIECE_LINK_DUPLICATE_MESSAGE,
|
||||
CONTENT_PIECE_LINK_INVALID_MESSAGE,
|
||||
CONTENT_PIECE_NOT_FOUND_MESSAGE,
|
||||
CONTENT_PIECE_RETRY_NOT_FAILED_MESSAGE,
|
||||
CONTENT_PIECE_SAFE_MESSAGES,
|
||||
contentPieceBatchRequestSchema,
|
||||
contentPieceDeleteRequestSchema,
|
||||
contentPieceLinkRequestSchema,
|
||||
contentPieceProfileImportRequestSchema,
|
||||
|
|
@ -26,12 +30,14 @@ import {
|
|||
contentPieceRetryRequestSchema,
|
||||
contentPieceStatusRequestSchema,
|
||||
contentPieceUpdateRequestSchema,
|
||||
type ContentPieceBatchRequest,
|
||||
type ContentPieceStatusRequest,
|
||||
type ContentPieceUpdateRequest,
|
||||
} from '@/lib/schemas/contentPiece'
|
||||
import type { SpeechTopic } from '@/lib/speechFacets'
|
||||
import type { CampaignUser } from '@/payload-types'
|
||||
import { getCampaignActionContext } from '@/utilities/campaignActionContext'
|
||||
import { mapCampaignFormActionError } from '@/utilities/campaignFormActionError'
|
||||
import { reapStaleContentPiece } from '@/utilities/content/contentPieceJob'
|
||||
import {
|
||||
searchContentPieceLeaderOptions,
|
||||
|
|
@ -278,6 +284,58 @@ export const deleteContentPieceForActor = async (input: {
|
|||
return { deleted: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* C236 — applies the same verdict to a bounded set of pieces. The batch is the
|
||||
* unit gesture repeated, so every id goes through the unit action: its own
|
||||
* transaction, its own fresh gate, its own post-commit media cleanup, its own
|
||||
* collection access as the final barrier. A failure is named in the outcome and
|
||||
* never reverts the pieces that succeeded (the product's honest partial
|
||||
* failure); the communication gate is checked once before the loop, so a denied
|
||||
* actor fails the whole request instead of turning into per-item noise. The
|
||||
* loop is sequential on purpose: the page ceiling is 25 ids and determinism
|
||||
* beats latency for a desk gesture.
|
||||
*/
|
||||
export const applyContentPieceBatchActionForActor = async (
|
||||
input: ContentPieceBatchRequest,
|
||||
): Promise<ContentPieceBatchOutcome> => {
|
||||
const parsed = contentPieceBatchRequestSchema.parse(input)
|
||||
const { actor } = await getCampaignActionContext()
|
||||
|
||||
if (!canReadCommunicationCatalog(actor.role)) throw new Error(CONTENT_PIECE_FORBIDDEN_MESSAGE)
|
||||
|
||||
// A repeated id would act twice on the same row (the second one failing as
|
||||
// not found), so the batch normalizes the selection before looping.
|
||||
const contentPieceIds = [...new Set(parsed.contentPieceIds)]
|
||||
const failures: ContentPieceBatchOutcome['failures'] = []
|
||||
let affected = 0
|
||||
|
||||
for (const contentPieceId of contentPieceIds) {
|
||||
try {
|
||||
if (parsed.action === 'apagar') {
|
||||
await deleteContentPieceForActor({ contentPieceId })
|
||||
} else {
|
||||
await setContentPiecePublishedForActor({
|
||||
contentPieceId,
|
||||
published: parsed.action === 'publicar',
|
||||
})
|
||||
}
|
||||
affected += 1
|
||||
} catch (error) {
|
||||
failures.push({
|
||||
contentPieceId,
|
||||
message:
|
||||
mapCampaignFormActionError({
|
||||
error,
|
||||
safeMessages: CONTENT_PIECE_SAFE_MESSAGES,
|
||||
genericMessage: CONTENT_PIECE_GENERIC_ERROR_MESSAGE,
|
||||
}).message ?? CONTENT_PIECE_GENERIC_ERROR_MESSAGE,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { action: parsed.action, affected, failures }
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls the statuses of the visible pieces; a stale `processando` row is reaped
|
||||
* to `falhou` on the way (there is no queue to ask), then the page is read
|
||||
|
|
|
|||
60
src/components/campaign/content/ContentPieceBatchReceipt.tsx
Normal file
60
src/components/campaign/content/ContentPieceBatchReceipt.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
'use client'
|
||||
|
||||
import { CircleAlertIcon, CircleCheckIcon, XIcon } from 'lucide-react'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import { useContentPieceSelection } from '@/components/campaign/content/ContentPieceSelectionProvider'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/** How long an all-success receipt stays before it fades on its own. */
|
||||
const RECEIPT_AUTO_DISMISS_MS = 6_000
|
||||
|
||||
/**
|
||||
* C236 — the honest receipt of the batch (approved design scene 5): a single
|
||||
* inline banner at the top of the list, never over it. Success is one neutral
|
||||
* phrase with a green icon and dismisses itself; a partial failure is an
|
||||
* assertive `role="alert"` that always separates affected from failures and
|
||||
* stays until it is closed.
|
||||
*/
|
||||
export const ContentPieceBatchReceipt = () => {
|
||||
const { receipt, dismissReceipt } = useContentPieceSelection()
|
||||
|
||||
useEffect(() => {
|
||||
if (receipt?.kind !== 'success') return
|
||||
const timer = window.setTimeout(dismissReceipt, RECEIPT_AUTO_DISMISS_MS)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [receipt, dismissReceipt])
|
||||
|
||||
if (!receipt) return null
|
||||
|
||||
const success = receipt.kind === 'success'
|
||||
|
||||
return (
|
||||
<div
|
||||
role={success ? 'status' : 'alert'}
|
||||
aria-live={success ? 'polite' : undefined}
|
||||
aria-atomic="true"
|
||||
className={cn(
|
||||
'mt-4 flex items-start gap-2 rounded-lg border bg-card px-3 py-2.5',
|
||||
success ? 'border-border' : 'border-destructive/40',
|
||||
)}
|
||||
>
|
||||
{success ? (
|
||||
<CircleCheckIcon className="mt-0.5 size-4 shrink-0 text-green-700" aria-hidden="true" />
|
||||
) : (
|
||||
<CircleAlertIcon className="mt-0.5 size-4 shrink-0 text-destructive" aria-hidden="true" />
|
||||
)}
|
||||
<p className={cn('flex-1 text-sm font-medium', !success && 'text-destructive')}>
|
||||
{receipt.message}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Fechar aviso"
|
||||
className="grid size-9 place-items-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
onClick={dismissReceipt}
|
||||
>
|
||||
<XIcon className="size-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import Link from 'next/link'
|
|||
import { ContentPieceCirculationCounters } from '@/components/campaign/content/ContentPieceCirculationCounters'
|
||||
import { ContentPieceDeleteDialog } from '@/components/campaign/content/ContentPieceDeleteDialog'
|
||||
import { ContentPieceRetryButton } from '@/components/campaign/content/ContentPieceRetryButton'
|
||||
import { ContentPieceCardSelectionFrame } from '@/components/campaign/content/ContentPieceSelectionControls'
|
||||
import {
|
||||
ContentPieceProcessingBadge,
|
||||
ContentPiecePublicationBadge,
|
||||
|
|
@ -18,7 +19,9 @@ import type { ContentPieceRowViewModel } from '@/lib/contentPieceCirculation'
|
|||
* carries the neutral "Peça-link" label — the honest reason lives on the ficha,
|
||||
* never on the list (C220 D7). C213 adds the piece's circulation counters (2×2
|
||||
* grid) right above the action. C222 adds "Apagar" beside the routine action
|
||||
* (below it when the piece can be reprocessed), per the approved scene B.
|
||||
* (below it when the piece can be reprocessed), per the approved scene B. C236
|
||||
* hands the article box to the selection frame, which adds the 44px checkbox
|
||||
* and the primary ring in selection mode without touching the body.
|
||||
*/
|
||||
export const ContentPieceCardList = ({
|
||||
rows,
|
||||
|
|
@ -40,21 +43,31 @@ export const ContentPieceCardList = ({
|
|||
...(isLinkOnly ? [] : [piece.typeLabel, piece.durationLabel]),
|
||||
]
|
||||
return (
|
||||
<article key={piece.id} className="rounded-xl border bg-card p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<Link href={piece.detailHref} className="min-w-0 text-sm font-semibold hover:underline">
|
||||
{piece.title}
|
||||
</Link>
|
||||
<ContentPieceProcessingBadge status={piece.processingStatus} className="shrink-0" />
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<ContentPiecePublicationBadge status={piece.status} />
|
||||
<span>
|
||||
{meta.filter(Boolean).join(' · ')}
|
||||
{piece.cityLabel ? ` · ${piece.cityLabel}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ContentPieceCardSelectionFrame
|
||||
key={piece.id}
|
||||
contentPieceId={piece.id}
|
||||
title={piece.title}
|
||||
header={
|
||||
<>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<Link
|
||||
href={piece.detailHref}
|
||||
className="block min-w-0 text-sm font-semibold break-words line-clamp-2 hover:underline"
|
||||
>
|
||||
{piece.title}
|
||||
</Link>
|
||||
<ContentPieceProcessingBadge status={piece.processingStatus} className="shrink-0" />
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<ContentPiecePublicationBadge status={piece.status} />
|
||||
<span>
|
||||
{meta.filter(Boolean).join(' · ')}
|
||||
{piece.cityLabel ? ` · ${piece.cityLabel}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{piece.processingStatus === 'processando' ? (
|
||||
<>
|
||||
<div className="mt-3 h-2 overflow-hidden rounded-full bg-muted">
|
||||
|
|
@ -96,7 +109,7 @@ export const ContentPieceCardList = ({
|
|||
/>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
</ContentPieceCardSelectionFrame>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
|
|
|||
141
src/components/campaign/content/ContentPieceSelectionBar.tsx
Normal file
141
src/components/campaign/content/ContentPieceSelectionBar.tsx
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
'use client'
|
||||
|
||||
import { BookmarkIcon, CheckIcon, Trash2Icon, XIcon } from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
import { useContentPieceSelection } from '@/components/campaign/content/ContentPieceSelectionProvider'
|
||||
import { CampaignDeleteDialog } from '@/components/campaign/shared/CampaignDeleteDialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
import {
|
||||
CONTENT_PIECE_BATCH_DELETE_IRREVERSIBLE_MESSAGE,
|
||||
CONTENT_PIECE_BATCH_DELETE_LINKS_MESSAGE,
|
||||
CONTENT_PIECE_BATCH_ERROR_MESSAGE,
|
||||
contentPieceBatchDeleteTitle,
|
||||
contentPieceSelectionLabel,
|
||||
type ContentPieceBatchAction,
|
||||
} from '@/lib/contentPieceBatch'
|
||||
|
||||
/**
|
||||
* C236 — the batch action bar (approved design scenes 2, 3b and 6): it exists
|
||||
* only while something is selected, floats at the footer of the content and
|
||||
* owns the three verbs. `Publicar`/`Despublicar` are the reversible kill switch
|
||||
* and act on the spot, with the receipt in the list; `Apagar` always opens the
|
||||
* one confirmation machine (the C222 chrome) with the link warning when the
|
||||
* selection carries published pieces. While the loop runs every target blocks:
|
||||
* `aria-busy` on the bar, spinner on the clicked verb, the rest disabled.
|
||||
*/
|
||||
export const ContentPieceSelectionBar = () => {
|
||||
const router = useRouter()
|
||||
const { selectedCount, selectedPublishedPaths, pendingAction, exitSelection, runBatch } =
|
||||
useContentPieceSelection()
|
||||
|
||||
if (selectedCount === 0) return null
|
||||
|
||||
const pending = pendingAction !== null
|
||||
const barButtonClassName = 'min-h-11 px-1 text-xs sm:px-3 sm:text-sm'
|
||||
|
||||
const run = async (action: ContentPieceBatchAction) => {
|
||||
const result = await runBatch(action)
|
||||
if (result.ok) router.refresh()
|
||||
}
|
||||
|
||||
const description = (() => {
|
||||
if (selectedPublishedPaths.length === 0) return CONTENT_PIECE_BATCH_DELETE_IRREVERSIBLE_MESSAGE
|
||||
if (selectedCount > 1) return CONTENT_PIECE_BATCH_DELETE_LINKS_MESSAGE
|
||||
return (
|
||||
<>
|
||||
O link público{' '}
|
||||
<span className="font-medium text-foreground">{selectedPublishedPaths[0]}</span> deixa de
|
||||
funcionar para quem já recebeu. Esta ação não pode ser desfeita.
|
||||
</>
|
||||
)
|
||||
})()
|
||||
|
||||
const deleteTrigger = (
|
||||
<Button type="button" variant="destructive" className={barButtonClassName} disabled={pending}>
|
||||
{pendingAction === 'apagar' ? (
|
||||
<Spinner data-icon="inline-start" aria-hidden="true" />
|
||||
) : (
|
||||
<Trash2Icon data-icon="inline-start" aria-hidden="true" />
|
||||
)}
|
||||
Apagar
|
||||
</Button>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="sticky bottom-32 z-20 mt-4 md:bottom-4 md:mr-16">
|
||||
<div
|
||||
aria-busy={pending}
|
||||
className="flex flex-col rounded-xl border border-border bg-card p-3 shadow-[0_10px_34px_rgb(28_25_23_/_0.16)] sm:flex-row sm:items-center sm:justify-between sm:gap-4 sm:px-3 sm:py-2.5"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 sm:justify-start">
|
||||
<p
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className={
|
||||
pending
|
||||
? 'text-sm font-medium tabular-nums text-muted-foreground'
|
||||
: 'text-sm font-medium tabular-nums'
|
||||
}
|
||||
>
|
||||
{contentPieceSelectionLabel(selectedCount)}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="min-h-11 sm:hidden"
|
||||
aria-label="Sair da seleção"
|
||||
disabled={pending}
|
||||
onClick={exitSelection}
|
||||
>
|
||||
<XIcon aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 grid grid-cols-3 gap-2 sm:mt-0 sm:flex sm:items-center">
|
||||
<Button
|
||||
type="button"
|
||||
className={barButtonClassName}
|
||||
disabled={pending}
|
||||
onClick={() => void run('publicar')}
|
||||
>
|
||||
{pendingAction === 'publicar' ? (
|
||||
<Spinner data-icon="inline-start" aria-hidden="true" />
|
||||
) : (
|
||||
<CheckIcon data-icon="inline-start" aria-hidden="true" />
|
||||
)}
|
||||
Publicar
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className={barButtonClassName}
|
||||
disabled={pending}
|
||||
onClick={() => void run('despublicar')}
|
||||
>
|
||||
{pendingAction === 'despublicar' ? (
|
||||
<Spinner data-icon="inline-start" aria-hidden="true" />
|
||||
) : (
|
||||
<BookmarkIcon data-icon="inline-start" aria-hidden="true" />
|
||||
)}
|
||||
Despublicar
|
||||
</Button>
|
||||
<CampaignDeleteDialog
|
||||
errorMessage={CONTENT_PIECE_BATCH_ERROR_MESSAGE}
|
||||
title={contentPieceBatchDeleteTitle(selectedCount)}
|
||||
titleClassName="border-b-0 pb-0"
|
||||
contentClassName="max-w-sm"
|
||||
description={description}
|
||||
confirmLabel="Apagar"
|
||||
trigger={deleteTrigger}
|
||||
onConfirm={async () => {
|
||||
const result = await runBatch('apagar')
|
||||
return result.ok ? { ok: true } : { ok: false, message: result.message }
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,156 @@
|
|||
'use client'
|
||||
|
||||
import { SquareCheckIcon, XIcon } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { useContentPieceSelection } from '@/components/campaign/content/ContentPieceSelectionProvider'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/Checkbox'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* C236 — the affordances of the selection mode (approved design scenes 1–3):
|
||||
* the single entry at the top of the list, the mode badge with its exit, the
|
||||
* per-row checkboxes of the table and the mobile card frame. All of them are
|
||||
* client islands reading the one selection context; the table shell and the
|
||||
* card bodies stay server-rendered.
|
||||
*/
|
||||
|
||||
/** The opt-in entry ("Selecionar") and the active-mode chrome of the top of the list. */
|
||||
export const ContentPieceSelectionModeControl = () => {
|
||||
const { selectionMode, enterSelection, exitSelection, pendingAction } = useContentPieceSelection()
|
||||
|
||||
if (!selectionMode) {
|
||||
return (
|
||||
<div className="mt-4 flex items-center justify-end">
|
||||
<Button type="button" variant="outline" className="min-h-11" onClick={enterSelection}>
|
||||
<SquareCheckIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Selecionar
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-4 flex items-center justify-between gap-3">
|
||||
<span className="inline-flex items-center gap-2 rounded-full bg-primary/10 px-3 py-1 text-xs font-medium text-primary">
|
||||
<SquareCheckIcon className="size-3.5" aria-hidden="true" />
|
||||
Modo de seleção
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="min-h-11 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Cancelar seleção"
|
||||
disabled={pendingAction !== null}
|
||||
onClick={exitSelection}
|
||||
>
|
||||
<XIcon data-icon="inline-start" aria-hidden="true" />
|
||||
<span aria-hidden="true" className="max-sm:hidden">
|
||||
Cancelar seleção
|
||||
</span>
|
||||
<span aria-hidden="true" className="sm:hidden">
|
||||
Cancelar
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** The header checkbox: all of the visible page, or none. */
|
||||
export const ContentPieceSelectAllCheckbox = () => {
|
||||
const { allSelected, selectedCount, toggleAll, pendingAction } = useContentPieceSelection()
|
||||
|
||||
return (
|
||||
<span className="grid size-9 place-items-center">
|
||||
<Checkbox
|
||||
checked={allSelected ? true : selectedCount > 0 ? 'indeterminate' : false}
|
||||
onCheckedChange={() => toggleAll()}
|
||||
disabled={pendingAction !== null}
|
||||
aria-label="Selecionar todas as peças desta página"
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One table-row checkbox. The wrapper carries `data-selection-state`, which the
|
||||
* row's `has-[…]:bg-primary/5` class uses to tint the selected line.
|
||||
*/
|
||||
export const ContentPieceRowCheckbox = ({
|
||||
contentPieceId,
|
||||
title,
|
||||
}: {
|
||||
contentPieceId: number
|
||||
title: string
|
||||
}) => {
|
||||
const { isSelected, toggle, pendingAction } = useContentPieceSelection()
|
||||
const selected = isSelected(contentPieceId)
|
||||
|
||||
return (
|
||||
<span
|
||||
className="grid size-9 place-items-center"
|
||||
data-selection-state={selected ? 'selected' : undefined}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selected}
|
||||
onCheckedChange={() => toggle(contentPieceId)}
|
||||
disabled={pendingAction !== null}
|
||||
aria-label={`Selecionar ${title}`}
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The mobile card frame (design scene 3b): in selection mode the article gains
|
||||
* the 44px touch target with the checkbox and the primary ring; outside it the
|
||||
* frame renders the exact resting article, so the card look at rest does not
|
||||
* depend on this island. `header` is the card's title/meta block (which sits
|
||||
* beside the checkbox); children are the rest of the card body.
|
||||
*/
|
||||
export const ContentPieceCardSelectionFrame = ({
|
||||
contentPieceId,
|
||||
title,
|
||||
header,
|
||||
children,
|
||||
}: {
|
||||
contentPieceId: number
|
||||
title: string
|
||||
header: ReactNode
|
||||
children: ReactNode
|
||||
}) => {
|
||||
const { selectionMode, isSelected, toggle, pendingAction } = useContentPieceSelection()
|
||||
const selected = selectionMode && isSelected(contentPieceId)
|
||||
|
||||
if (!selectionMode) {
|
||||
return (
|
||||
<article className="rounded-xl border bg-card p-4">
|
||||
{header}
|
||||
{children}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<article
|
||||
className={cn(
|
||||
'rounded-xl border border-border bg-card p-4',
|
||||
selected && 'ring-1 ring-primary/25',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="grid size-11 shrink-0 place-items-center">
|
||||
<Checkbox
|
||||
checked={selected}
|
||||
onCheckedChange={() => toggle(contentPieceId)}
|
||||
disabled={pendingAction !== null}
|
||||
aria-label={`Selecionar ${title}`}
|
||||
/>
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">{header}</div>
|
||||
</div>
|
||||
{children}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,201 @@
|
|||
'use client'
|
||||
|
||||
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from 'react'
|
||||
|
||||
import type { ContentPieceBatchResponse } from '@/app/(campaign)/campanha/(app)/comunicacao/conteudos/types'
|
||||
import { postCampaignJson } from '@/lib/campaignJsonRequest'
|
||||
import { CAMPAIGN_CONTENT_PIECE_BATCH_HREF } from '@/lib/campaignPaths'
|
||||
import {
|
||||
CONTENT_PIECE_BATCH_ERROR_MESSAGE,
|
||||
contentPieceBatchReceiptMessage,
|
||||
type ContentPieceBatchAction,
|
||||
type ContentPieceBatchOutcome,
|
||||
type ContentPieceSelectionRow,
|
||||
} from '@/lib/contentPieceBatch'
|
||||
|
||||
/** What the receipt banner renders: success is `status`, a failure is `alert`. */
|
||||
export type ContentPieceReceipt = {
|
||||
kind: 'success' | 'failure'
|
||||
message: string
|
||||
}
|
||||
|
||||
export type ContentPieceBatchResult =
|
||||
| { ok: true; outcome: ContentPieceBatchOutcome }
|
||||
| { ok: false; message: string }
|
||||
|
||||
type ContentPieceSelectionValue = {
|
||||
selectionMode: boolean
|
||||
selectedCount: number
|
||||
allSelected: boolean
|
||||
isSelected: (id: number) => boolean
|
||||
pendingAction: ContentPieceBatchAction | null
|
||||
receipt: ContentPieceReceipt | null
|
||||
/** `/conteudos/<slug>` of the selected published pieces (the delete warning). */
|
||||
selectedPublishedPaths: readonly string[]
|
||||
enterSelection: () => void
|
||||
exitSelection: () => void
|
||||
toggle: (id: number) => void
|
||||
toggleAll: () => void
|
||||
dismissReceipt: () => void
|
||||
runBatch: (action: ContentPieceBatchAction) => Promise<ContentPieceBatchResult>
|
||||
}
|
||||
|
||||
const ContentPieceSelectionContext = createContext<ContentPieceSelectionValue | null>(null)
|
||||
|
||||
export const useContentPieceSelection = (): ContentPieceSelectionValue => {
|
||||
const value = useContext(ContentPieceSelectionContext)
|
||||
if (!value) {
|
||||
throw new Error('useContentPieceSelection precisa de um ContentPieceSelectionProvider.')
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* C236 — the one selection state shared by the two bodies of the list (desktop
|
||||
* table + mobile cards) and by the action bar. Client-only by design: the two
|
||||
* RSC bodies and the shell stay untouched; the provider receives a serializable
|
||||
* projection of the visible page and wraps them via `children`.
|
||||
*
|
||||
* The selection is page-scoped and dies on navigation: the page keys this
|
||||
* provider by the canonical list URL, so a new page/filter remounts it. A
|
||||
* refresh of the same key (post-action, status poll) preserves the state, and
|
||||
* `runBatch` clears the ids on success so the receipt is about a finished
|
||||
* gesture. A whole-request failure keeps the selection — nothing changed.
|
||||
*/
|
||||
export const ContentPieceSelectionProvider = ({
|
||||
rows,
|
||||
children,
|
||||
}: {
|
||||
rows: readonly ContentPieceSelectionRow[]
|
||||
children: ReactNode
|
||||
}) => {
|
||||
const [selectionMode, setSelectionMode] = useState(false)
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>([])
|
||||
const [pendingAction, setPendingAction] = useState<ContentPieceBatchAction | null>(null)
|
||||
const [receipt, setReceipt] = useState<ContentPieceReceipt | null>(null)
|
||||
|
||||
const pageIds = useMemo(() => rows.map((row) => row.id), [rows])
|
||||
const pageIdSet = useMemo(() => new Set(pageIds), [pageIds])
|
||||
// The selection never outgrows the visible page: an id that left the page
|
||||
// (the status poll can change the filtered rows under the same URL key) stops
|
||||
// counting and never reaches the batch.
|
||||
const visibleSelectedIds = useMemo(
|
||||
() => selectedIds.filter((id) => pageIdSet.has(id)),
|
||||
[selectedIds, pageIdSet],
|
||||
)
|
||||
const selectedSet = useMemo(() => new Set(visibleSelectedIds), [visibleSelectedIds])
|
||||
const isSelected = useCallback((id: number) => selectedSet.has(id), [selectedSet])
|
||||
const selectedPublishedPaths = useMemo(
|
||||
() =>
|
||||
rows.flatMap((row) =>
|
||||
selectedSet.has(row.id) && row.status === 'publicado' && row.publicPath !== null
|
||||
? [row.publicPath]
|
||||
: [],
|
||||
),
|
||||
[rows, selectedSet],
|
||||
)
|
||||
|
||||
const enterSelection = useCallback(() => {
|
||||
setSelectionMode(true)
|
||||
setReceipt(null)
|
||||
}, [])
|
||||
|
||||
const exitSelection = useCallback(() => {
|
||||
setSelectionMode(false)
|
||||
setSelectedIds([])
|
||||
}, [])
|
||||
|
||||
const toggle = useCallback((id: number) => {
|
||||
setSelectedIds((current) =>
|
||||
current.includes(id) ? current.filter((value) => value !== id) : [...current, id],
|
||||
)
|
||||
}, [])
|
||||
|
||||
const toggleAll = useCallback(() => {
|
||||
setSelectedIds((current) =>
|
||||
current.filter((id) => pageIdSet.has(id)).length === pageIds.length ? [] : [...pageIds],
|
||||
)
|
||||
}, [pageIds, pageIdSet])
|
||||
|
||||
const dismissReceipt = useCallback(() => setReceipt(null), [])
|
||||
|
||||
const runBatch = useCallback(
|
||||
async (action: ContentPieceBatchAction): Promise<ContentPieceBatchResult> => {
|
||||
setPendingAction(action)
|
||||
|
||||
try {
|
||||
const { ok, payload } = await postCampaignJson<ContentPieceBatchResponse>(
|
||||
CAMPAIGN_CONTENT_PIECE_BATCH_HREF,
|
||||
{ action, contentPieceIds: visibleSelectedIds },
|
||||
)
|
||||
|
||||
if (!ok || payload.status !== 'success') {
|
||||
const message =
|
||||
payload.status === 'error' ? payload.message : CONTENT_PIECE_BATCH_ERROR_MESSAGE
|
||||
// The delete failure belongs to the confirmation dialog; the
|
||||
// reversible verbs answer in the list, right where the selection is.
|
||||
if (action !== 'apagar') setReceipt({ kind: 'failure', message })
|
||||
return { ok: false, message }
|
||||
}
|
||||
|
||||
setReceipt({
|
||||
kind: payload.outcome.failures.length === 0 ? 'success' : 'failure',
|
||||
message: contentPieceBatchReceiptMessage(payload.outcome),
|
||||
})
|
||||
setSelectedIds([])
|
||||
return { ok: true, outcome: payload.outcome }
|
||||
} catch {
|
||||
if (action !== 'apagar') {
|
||||
setReceipt({ kind: 'failure', message: CONTENT_PIECE_BATCH_ERROR_MESSAGE })
|
||||
}
|
||||
return { ok: false, message: CONTENT_PIECE_BATCH_ERROR_MESSAGE }
|
||||
} finally {
|
||||
setPendingAction(null)
|
||||
}
|
||||
},
|
||||
[visibleSelectedIds],
|
||||
)
|
||||
|
||||
const value = useMemo<ContentPieceSelectionValue>(
|
||||
() => ({
|
||||
selectionMode,
|
||||
selectedCount: visibleSelectedIds.length,
|
||||
allSelected: pageIds.length > 0 && visibleSelectedIds.length === pageIds.length,
|
||||
isSelected,
|
||||
pendingAction,
|
||||
receipt,
|
||||
selectedPublishedPaths,
|
||||
enterSelection,
|
||||
exitSelection,
|
||||
toggle,
|
||||
toggleAll,
|
||||
dismissReceipt,
|
||||
runBatch,
|
||||
}),
|
||||
[
|
||||
selectionMode,
|
||||
visibleSelectedIds,
|
||||
pageIds,
|
||||
isSelected,
|
||||
pendingAction,
|
||||
receipt,
|
||||
selectedPublishedPaths,
|
||||
enterSelection,
|
||||
exitSelection,
|
||||
toggle,
|
||||
toggleAll,
|
||||
dismissReceipt,
|
||||
runBatch,
|
||||
],
|
||||
)
|
||||
|
||||
return (
|
||||
<ContentPieceSelectionContext.Provider value={value}>
|
||||
{/* `contents` keeps the wrapper out of the layout; `group` + the data
|
||||
attribute are what the table column CSS reads to show/hide itself. */}
|
||||
<div className="group contents" data-selection-mode={selectionMode ? 'true' : undefined}>
|
||||
{children}
|
||||
</div>
|
||||
</ContentPieceSelectionContext.Provider>
|
||||
)
|
||||
}
|
||||
|
|
@ -4,6 +4,10 @@ import Link from 'next/link'
|
|||
import { ContentPieceCirculationCounters } from '@/components/campaign/content/ContentPieceCirculationCounters'
|
||||
import { ContentPieceDeleteDialog } from '@/components/campaign/content/ContentPieceDeleteDialog'
|
||||
import { ContentPieceRetryButton } from '@/components/campaign/content/ContentPieceRetryButton'
|
||||
import {
|
||||
ContentPieceRowCheckbox,
|
||||
ContentPieceSelectAllCheckbox,
|
||||
} from '@/components/campaign/content/ContentPieceSelectionControls'
|
||||
import {
|
||||
ContentPieceProcessingBadge,
|
||||
ContentPiecePublicationBadge,
|
||||
|
|
@ -60,7 +64,24 @@ export const ContentPieceTable = ({
|
|||
rows={rows}
|
||||
rowKey={(row) => row.id}
|
||||
empty={empty}
|
||||
rowClassName="has-[[data-selection-state=selected]]:bg-primary/5"
|
||||
columns={[
|
||||
{
|
||||
// C236 — the selection column is chrome of the selection mode, not a
|
||||
// data column: it is mandatory (a stale cookie can never hide it) and
|
||||
// the CSS shows it only while the provider is in selection mode. It is
|
||||
// deliberately absent from the column picker of the page.
|
||||
id: 'selection',
|
||||
label: 'Seleção',
|
||||
mandatory: true,
|
||||
head: (
|
||||
<CampaignTableHead className="hidden w-12 group-data-[selection-mode=true]:table-cell">
|
||||
<ContentPieceSelectAllCheckbox />
|
||||
</CampaignTableHead>
|
||||
),
|
||||
cellClassName: 'hidden group-data-[selection-mode=true]:table-cell',
|
||||
cell: (piece) => <ContentPieceRowCheckbox contentPieceId={piece.id} title={piece.title} />,
|
||||
},
|
||||
{
|
||||
id: 'piece',
|
||||
label: 'Peça',
|
||||
|
|
@ -75,7 +96,10 @@ export const ContentPieceTable = ({
|
|||
<Icon className="size-5" aria-hidden="true" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<Link href={piece.detailHref} className="font-semibold hover:underline">
|
||||
<Link
|
||||
href={piece.detailHref}
|
||||
className="block min-w-0 font-semibold break-words line-clamp-2 hover:underline"
|
||||
>
|
||||
{piece.title}
|
||||
</Link>
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground">{metaLine(piece)}</p>
|
||||
|
|
|
|||
|
|
@ -25,9 +25,7 @@ import { deleteCampaignJson } from '@/lib/campaignJsonRequest'
|
|||
*/
|
||||
type CampaignDeleteDialogResponse = { status: 'success' } | { status: 'error'; message: string }
|
||||
|
||||
type CampaignDeleteDialogProps = {
|
||||
/** The DELETE endpoint of the row (the id lives in the path). */
|
||||
endpoint: string
|
||||
type CampaignDeleteDialogBaseProps = {
|
||||
/** Fallback copy for a transport/unknown failure; domain errors travel from the route. */
|
||||
errorMessage: string
|
||||
/** The styled trigger button of the caller (the machine owns the confirm flow). */
|
||||
|
|
@ -41,27 +39,50 @@ type CampaignDeleteDialogProps = {
|
|||
titleClassName?: string
|
||||
/** Callers whose footer chrome differs from the default pass their own. */
|
||||
footerClassName?: string
|
||||
/** Callers whose panel width differs from the default pass their own (C236 batch). */
|
||||
contentClassName?: string
|
||||
}
|
||||
|
||||
type CampaignDeleteDialogProps = CampaignDeleteDialogBaseProps &
|
||||
(
|
||||
| {
|
||||
/** The DELETE endpoint of the row (the id lives in the path). */
|
||||
endpoint: string
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* C236 — a caller that owns the whole request (the batch loop) passes
|
||||
* the mutation instead of a DELETE endpoint: the machine calls it on
|
||||
* confirm and keeps owning submitting/error/success.
|
||||
*/
|
||||
onConfirm: () => Promise<{ ok: true } | { ok: false; message: string }>
|
||||
/** Runs after a successful confirm, before the refresh/redirect. */
|
||||
onSuccess?: () => void
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* The one delete-confirmation machine of the campaign vertical (C183 built it
|
||||
* for the cut, C199 reused it for the recording, C222 is the third call site —
|
||||
* extracted here instead of copied again). Owns the submitting/error state, the
|
||||
* DELETE call, the redirect/refresh decision and the dialog chrome; each caller
|
||||
* passes policy only (endpoint, copy, trigger, classes). The error always stays
|
||||
* in the dialog context, never a toast.
|
||||
* in the dialog context, never a toast. C236 adds the `onConfirm` path: a
|
||||
* caller that owns the whole request (the batch loop) passes the mutation
|
||||
* itself and the machine keeps the same chrome and flow.
|
||||
*/
|
||||
export const CampaignDeleteDialog = ({
|
||||
endpoint,
|
||||
errorMessage,
|
||||
trigger,
|
||||
title,
|
||||
description,
|
||||
confirmLabel,
|
||||
redirectTo,
|
||||
titleClassName,
|
||||
footerClassName,
|
||||
}: CampaignDeleteDialogProps) => {
|
||||
export const CampaignDeleteDialog = (props: CampaignDeleteDialogProps) => {
|
||||
const {
|
||||
errorMessage,
|
||||
trigger,
|
||||
title,
|
||||
description,
|
||||
confirmLabel,
|
||||
redirectTo,
|
||||
titleClassName,
|
||||
footerClassName,
|
||||
contentClassName,
|
||||
} = props
|
||||
const router = useRouter()
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
|
@ -71,7 +92,19 @@ export const CampaignDeleteDialog = ({
|
|||
setError(null)
|
||||
|
||||
try {
|
||||
const { ok, payload } = await deleteCampaignJson<CampaignDeleteDialogResponse>(endpoint)
|
||||
if ('onConfirm' in props) {
|
||||
const result = await props.onConfirm()
|
||||
if (!result.ok) {
|
||||
setError(result.message)
|
||||
return
|
||||
}
|
||||
props.onSuccess?.()
|
||||
if (redirectTo) router.push(redirectTo)
|
||||
else router.refresh()
|
||||
return
|
||||
}
|
||||
|
||||
const { ok, payload } = await deleteCampaignJson<CampaignDeleteDialogResponse>(props.endpoint)
|
||||
if (!ok || payload.status !== 'success') {
|
||||
setError(payload.status === 'error' ? payload.message : errorMessage)
|
||||
return
|
||||
|
|
@ -88,7 +121,7 @@ export const CampaignDeleteDialog = ({
|
|||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>{trigger}</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogContent className={contentClassName}>
|
||||
<AlertDialogHeader>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="grid size-9 shrink-0 place-items-center rounded-full bg-red-50 text-destructive">
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ export const Spinner = ({ className, ...props }: React.ComponentProps<'svg'>) =>
|
|||
data-slot="spinner"
|
||||
role="status"
|
||||
aria-label="Carregando"
|
||||
className={cn('size-4 animate-spin', className)}
|
||||
className={cn('size-4 animate-spin motion-reduce:animate-none', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -103,6 +103,9 @@ export const campaignContentPiecePublicationHref = (id: number): string =>
|
|||
export const campaignContentPieceDeleteHref = (id: number): string =>
|
||||
`${campaignContentPieceDetailHref(id)}/apagar`
|
||||
|
||||
/** C236 — batch gateway of the Central list (publish/unpublish/delete a selection). */
|
||||
export const CAMPAIGN_CONTENT_PIECE_BATCH_HREF = `${CAMPAIGN_COMMUNICATION_CONTEUDOS}/lote` as const
|
||||
|
||||
const campaignRecordingDetailHref = (id: number): string =>
|
||||
`${CAMPAIGN_COMMUNICATION_ACERVO_GRAVACOES}/${id}`
|
||||
|
||||
|
|
|
|||
79
src/lib/contentPieceBatch.ts
Normal file
79
src/lib/contentPieceBatch.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
/**
|
||||
* C236 — pure vocabulary and copy of the batch actions of the Central de
|
||||
* Conteúdos: the three verbs, the selection counter, the delete confirmation
|
||||
* and the honest receipt (affected × failures). Client-safe: the selection
|
||||
* provider, the action bar, the confirmation dialog and the JSON route share
|
||||
* this module.
|
||||
*
|
||||
* The batch is the unit gesture repeated — it never invents a rule of its own,
|
||||
* so everything here is presentation of an outcome the unit actions produced.
|
||||
*/
|
||||
import type { ContentPieceStatus } from '@/lib/contentPiece'
|
||||
|
||||
export const CONTENT_PIECE_BATCH_ACTIONS = ['publicar', 'despublicar', 'apagar'] as const
|
||||
|
||||
export type ContentPieceBatchAction = (typeof CONTENT_PIECE_BATCH_ACTIONS)[number]
|
||||
|
||||
/** One item of the selection: the only fields the list projection needs. */
|
||||
export type ContentPieceSelectionRow = {
|
||||
id: number
|
||||
status: ContentPieceStatus
|
||||
/** The public `/conteudos/<slug>` when the piece has a slug; null otherwise. */
|
||||
publicPath: string | null
|
||||
}
|
||||
|
||||
type ContentPieceBatchFailure = {
|
||||
contentPieceId: number
|
||||
message: string
|
||||
}
|
||||
|
||||
/** What the loop produced: how many pieces changed and which ones did not. */
|
||||
export type ContentPieceBatchOutcome = {
|
||||
action: ContentPieceBatchAction
|
||||
affected: number
|
||||
failures: ContentPieceBatchFailure[]
|
||||
}
|
||||
|
||||
/** Transport failure of the whole request — never a per-piece failure. */
|
||||
export const CONTENT_PIECE_BATCH_ERROR_MESSAGE =
|
||||
'Não foi possível concluir a ação em lote. Tente novamente.'
|
||||
|
||||
/** The irreversible-only body of the mass delete (no published piece in the selection). */
|
||||
export const CONTENT_PIECE_BATCH_DELETE_IRREVERSIBLE_MESSAGE = 'Esta ação não pode ser desfeita.'
|
||||
|
||||
/** The body that warns about the public links when the selection has published pieces. */
|
||||
export const CONTENT_PIECE_BATCH_DELETE_LINKS_MESSAGE =
|
||||
'Os links públicos das peças publicadas da seleção deixam de funcionar para quem já recebeu. Esta ação não pode ser desfeita.'
|
||||
|
||||
/** `3 selecionadas` / `1 selecionada` (the TourComposerForm precedent). */
|
||||
export const contentPieceSelectionLabel = (count: number): string =>
|
||||
`${count} ${count === 1 ? 'selecionada' : 'selecionadas'}`
|
||||
|
||||
const PARTICIPLES: Record<ContentPieceBatchAction, readonly [string, string]> = {
|
||||
publicar: ['publicada', 'publicadas'],
|
||||
despublicar: ['despublicada', 'despublicadas'],
|
||||
apagar: ['apagada', 'apagadas'],
|
||||
}
|
||||
|
||||
/**
|
||||
* The honest receipt: an all-success outcome is one phrase; a partial one
|
||||
* always separates affected from failures and never says "3 peças apagadas"
|
||||
* when one failed.
|
||||
*/
|
||||
export const contentPieceBatchReceiptMessage = (outcome: ContentPieceBatchOutcome): string => {
|
||||
const [singular, plural] = PARTICIPLES[outcome.action]
|
||||
|
||||
if (outcome.failures.length === 0) {
|
||||
return `${outcome.affected} ${outcome.affected === 1 ? `peça ${singular}` : `peças ${plural}`}`
|
||||
}
|
||||
|
||||
const total = outcome.affected + outcome.failures.length
|
||||
const failed =
|
||||
outcome.failures.length === 1 ? '1 falhou.' : `${outcome.failures.length} falharam.`
|
||||
const noun = total === 1 ? 'peça' : 'peças'
|
||||
return `${outcome.affected} de ${total} ${noun} ${total === 1 ? singular : plural}. ${failed}`
|
||||
}
|
||||
|
||||
/** `Apagar 3 peças selecionadas?` — the singular reuses the C222 literal. */
|
||||
export const contentPieceBatchDeleteTitle = (count: number): string =>
|
||||
count === 1 ? 'Apagar esta peça?' : `Apagar ${count} peças selecionadas?`
|
||||
|
|
@ -11,6 +11,7 @@ import {
|
|||
CONTENT_PIECE_TITLE_MAX_LENGTH,
|
||||
CONTENT_PIECE_TYPES,
|
||||
} from '@/lib/contentPiece'
|
||||
import { CONTENT_PIECE_BATCH_ACTIONS } from '@/lib/contentPieceBatch'
|
||||
import { contentPieceProfilePeriodError } from '@/lib/contentPieceProfileWindow'
|
||||
import { trimmedNullableText } from '@/lib/schemas/primitives'
|
||||
import { TRANSCRIPT_TEXT_MAX_LENGTH } from '@/lib/speechSearch'
|
||||
|
|
@ -169,6 +170,18 @@ export const contentPiecePublicationRequestSchema = z.object({
|
|||
published: z.boolean(),
|
||||
})
|
||||
|
||||
/**
|
||||
* C236 — apply the same verdict to a bounded set of pieces: the selection is
|
||||
* one page of the list, so the ceiling mirrors the status poll (50 ids) and the
|
||||
* action dedupes whatever arrives.
|
||||
*/
|
||||
export const contentPieceBatchRequestSchema = z.object({
|
||||
action: z.enum(CONTENT_PIECE_BATCH_ACTIONS),
|
||||
contentPieceIds: z.array(z.number().int().positive()).min(1).max(50),
|
||||
})
|
||||
|
||||
export type ContentPieceBatchRequest = z.infer<typeof contentPieceBatchRequestSchema>
|
||||
|
||||
/** Attach the original file to a link piece. */
|
||||
export const contentPieceAttachRequestSchema = z.object({
|
||||
contentPieceId: z.number().int().positive(),
|
||||
|
|
|
|||
|
|
@ -1892,5 +1892,68 @@ test.describe('communication vertical (C154/C162)', () => {
|
|||
)
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
test('publishes and deletes a selection over HTTP, naming the partial failure (C236)', async ({
|
||||
campaign,
|
||||
campaignRequest,
|
||||
}) => {
|
||||
const marker = campaign.fixtures.value('pecalote')
|
||||
const published = await createPiece(campaign, { marker: `${marker}a`, status: 'publicado' })
|
||||
const draft = await createPiece(campaign, { marker: `${marker}b` })
|
||||
const loteUrl = '/campanha/comunicacao/conteudos/lote'
|
||||
|
||||
const communicator = await campaign.fixtures.createCampaignUser('communicator')
|
||||
const request = await campaignRequest(communicator, communicator.password)
|
||||
|
||||
// Fail-closed: an advisor is denied before any piece changes.
|
||||
const advisor = await campaign.fixtures.createCampaignUser('advisor')
|
||||
const deniedRequest = await campaignRequest(advisor, advisor.password)
|
||||
const denied = await deniedRequest.post(loteUrl, {
|
||||
data: { action: 'apagar', contentPieceIds: [published.piece.id] },
|
||||
})
|
||||
expect(denied.status()).toBe(400)
|
||||
expect(((await denied.json()) as { message: string }).message).toContain(
|
||||
'não tem acesso à Central de Conteúdos',
|
||||
)
|
||||
|
||||
// Publish the draft through the batch gateway.
|
||||
const publishBatch = await request.post(loteUrl, {
|
||||
data: { action: 'publicar', contentPieceIds: [draft.piece.id] },
|
||||
})
|
||||
expect(publishBatch.status()).toBe(200)
|
||||
expect(await publishBatch.json()).toEqual({
|
||||
status: 'success',
|
||||
outcome: { action: 'publicar', affected: 1, failures: [] },
|
||||
})
|
||||
|
||||
const slug = published.piece.slug!
|
||||
const missingId = published.piece.id + 1_000_000
|
||||
const deleteBatch = await request.post(loteUrl, {
|
||||
data: { action: 'apagar', contentPieceIds: [published.piece.id, missingId] },
|
||||
})
|
||||
expect(deleteBatch.status()).toBe(200)
|
||||
expect(await deleteBatch.json()).toEqual({
|
||||
status: 'success',
|
||||
outcome: {
|
||||
action: 'apagar',
|
||||
affected: 1,
|
||||
failures: [{ contentPieceId: missingId, message: 'Peça não encontrada.' }],
|
||||
},
|
||||
})
|
||||
|
||||
// The deleted row leaves the list; the draft published above stays.
|
||||
const afterList = rendered(
|
||||
await (await request.get(`/campanha/comunicacao/conteudos?q=${marker}`)).text(),
|
||||
)
|
||||
expect(afterList).not.toContain(`Peça ${marker}a`)
|
||||
expect(afterList).toContain(`Peça ${marker}b`)
|
||||
|
||||
const anonymous = await playwrightRequest.newContext({ baseURL: campaign.baseURL })
|
||||
try {
|
||||
expect((await anonymous.get(`/conteudos/${slug}`)).status()).toBe(404)
|
||||
} finally {
|
||||
await anonymous.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -34,8 +34,10 @@ vi.mock('@/utilities/campaignAuth', () => ({
|
|||
import { GET } from '@/app/(campaign)/campanha/(app)/comunicacao/conteudos/[id]/arquivo/route'
|
||||
import { POST as postProfileImportCreate } from '@/app/(campaign)/campanha/(app)/comunicacao/conteudos/importar/criar/route'
|
||||
import { POST as postProfileImport } from '@/app/(campaign)/campanha/(app)/comunicacao/conteudos/importar/route'
|
||||
import { POST as postBatch } from '@/app/(campaign)/campanha/(app)/comunicacao/conteudos/lote/route'
|
||||
import {
|
||||
addContentPieceByLinkForActor,
|
||||
applyContentPieceBatchActionForActor,
|
||||
createContentPieceFromProfilePostForActor,
|
||||
deleteContentPieceForActor,
|
||||
getContentPieceStatusesForActor,
|
||||
|
|
@ -850,6 +852,208 @@ describe('content pieces (C211)', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('batch actions (C236)', () => {
|
||||
const mediaGone = async (mediaId: number): Promise<boolean> => {
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
const gone = await payload
|
||||
.findByID({ collection: 'contentMedia', id: mediaId, overrideAccess: true })
|
||||
.then(() => false)
|
||||
.catch(() => true)
|
||||
if (gone) return true
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
it('publishes and unpublishes a selection with the unit semantics preserved', async () => {
|
||||
getCampaignUserMock.mockResolvedValue(communicator)
|
||||
const tag = getCollectionListingTag('contentPiece')
|
||||
const first = await createPiece({ title: `Lote publica ${Date.now()}a` })
|
||||
const second = await createPiece({ title: `Lote publica ${Date.now()}b` })
|
||||
|
||||
vi.mocked(revalidateTag).mockClear()
|
||||
await expect(
|
||||
applyContentPieceBatchActionForActor({
|
||||
action: 'publicar',
|
||||
contentPieceIds: [first.piece.id, second.piece.id],
|
||||
}),
|
||||
).resolves.toEqual({ action: 'publicar', affected: 2, failures: [] })
|
||||
expect(revalidateTag).toHaveBeenCalledWith(tag)
|
||||
|
||||
const publishedFirst = await payload.findByID({
|
||||
collection: 'contentPiece',
|
||||
id: first.piece.id,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
expect(publishedFirst.status).toBe('publicado')
|
||||
expect(publishedFirst.slug).toBeTruthy()
|
||||
|
||||
await expect(
|
||||
applyContentPieceBatchActionForActor({
|
||||
action: 'despublicar',
|
||||
contentPieceIds: [first.piece.id, second.piece.id],
|
||||
}),
|
||||
).resolves.toEqual({ action: 'despublicar', affected: 2, failures: [] })
|
||||
|
||||
const unpublished = await payload.findByID({
|
||||
collection: 'contentPiece',
|
||||
id: first.piece.id,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
// The kill switch preserves slug, publication date and the file.
|
||||
expect(unpublished.status).toBe('rascunho')
|
||||
expect(unpublished.slug).toBe(publishedFirst.slug)
|
||||
expect(unpublished.publishedAt).toBe(publishedFirst.publishedAt)
|
||||
expect(unpublished.media).toBe(first.media!.id)
|
||||
})
|
||||
|
||||
it('deletes a selection with its media, leaves the events and dedupes repeated ids', async () => {
|
||||
getCampaignUserMock.mockResolvedValue(communicator)
|
||||
const first = await createPiece({ title: `Lote apaga ${Date.now()}a`, status: 'publicado' })
|
||||
const second = await createPiece({ title: `Lote apaga ${Date.now()}b` })
|
||||
const event = await payload.create({
|
||||
collection: 'contentEvent',
|
||||
data: { type: 'abertura', subjectType: 'peca', subjectId: String(first.piece.id) },
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
||||
await expect(
|
||||
applyContentPieceBatchActionForActor({
|
||||
action: 'apagar',
|
||||
contentPieceIds: [first.piece.id, first.piece.id, second.piece.id],
|
||||
}),
|
||||
).resolves.toEqual({ action: 'apagar', affected: 2, failures: [] })
|
||||
createdPieceIds.delete(first.piece.id)
|
||||
createdPieceIds.delete(second.piece.id)
|
||||
|
||||
await expect(
|
||||
payload.findByID({ collection: 'contentPiece', id: second.piece.id, overrideAccess: true }),
|
||||
).rejects.toThrow()
|
||||
for (const media of [first.media, second.media]) {
|
||||
if (!media) continue
|
||||
expect(await mediaGone(media.id)).toBe(true)
|
||||
createdMediaIds.delete(media.id)
|
||||
}
|
||||
|
||||
const survivor = await payload.findByID({
|
||||
collection: 'contentEvent',
|
||||
id: event.id,
|
||||
overrideAccess: true,
|
||||
})
|
||||
expect(survivor.subjectId).toBe(String(first.piece.id))
|
||||
await payload.delete({ collection: 'contentEvent', id: event.id, overrideAccess: true })
|
||||
})
|
||||
|
||||
it('names the failing piece and never reverts the ones that succeeded', async () => {
|
||||
getCampaignUserMock.mockResolvedValue(communicator)
|
||||
const existing = await createPiece({ title: `Lote parcial ${Date.now()}` })
|
||||
const missingId = existing.piece.id + 100_000
|
||||
|
||||
await expect(
|
||||
applyContentPieceBatchActionForActor({
|
||||
action: 'apagar',
|
||||
contentPieceIds: [existing.piece.id, missingId],
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
action: 'apagar',
|
||||
affected: 1,
|
||||
failures: [{ contentPieceId: missingId, message: CONTENT_PIECE_NOT_FOUND_MESSAGE }],
|
||||
})
|
||||
createdPieceIds.delete(existing.piece.id)
|
||||
|
||||
await expect(
|
||||
payload.findByID({
|
||||
collection: 'contentPiece',
|
||||
id: existing.piece.id,
|
||||
overrideAccess: true,
|
||||
}),
|
||||
).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('gates the batch by role: denied roles fail whole, the vertical acts', async () => {
|
||||
const first = await createPiece({ title: `Lote negado ${Date.now()}a` })
|
||||
const second = await createPiece({ title: `Lote negado ${Date.now()}b` })
|
||||
|
||||
for (const denied of [advisor, leader]) {
|
||||
getCampaignUserMock.mockResolvedValue(denied)
|
||||
await expect(
|
||||
applyContentPieceBatchActionForActor({
|
||||
action: 'apagar',
|
||||
contentPieceIds: [first.piece.id, second.piece.id],
|
||||
}),
|
||||
).rejects.toThrow(CONTENT_PIECE_FORBIDDEN_MESSAGE)
|
||||
}
|
||||
|
||||
for (const piece of [first.piece, second.piece]) {
|
||||
await expect(
|
||||
payload.findByID({ collection: 'contentPiece', id: piece.id, overrideAccess: true }),
|
||||
).resolves.toMatchObject({ id: piece.id })
|
||||
}
|
||||
|
||||
// The vertical that owns the gesture acts through the same gate.
|
||||
for (const allowed of [coordinator, candidate]) {
|
||||
const { piece } = await createPiece({ title: `Lote permitido ${Date.now()}` })
|
||||
getCampaignUserMock.mockResolvedValue(allowed)
|
||||
await expect(
|
||||
applyContentPieceBatchActionForActor({
|
||||
action: 'publicar',
|
||||
contentPieceIds: [piece.id],
|
||||
}),
|
||||
).resolves.toEqual({ action: 'publicar', affected: 1, failures: [] })
|
||||
}
|
||||
})
|
||||
|
||||
it('bounds the batch with the request schema', async () => {
|
||||
getCampaignUserMock.mockResolvedValue(communicator)
|
||||
|
||||
await expect(
|
||||
applyContentPieceBatchActionForActor({ action: 'publicar', contentPieceIds: [] }),
|
||||
).rejects.toThrow()
|
||||
await expect(
|
||||
applyContentPieceBatchActionForActor({
|
||||
action: 'publicar',
|
||||
contentPieceIds: Array.from({ length: 51 }, (_, index) => index + 1),
|
||||
}),
|
||||
).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('answers the JSON envelope of the batch route', async () => {
|
||||
getCampaignUserMock.mockResolvedValue(communicator)
|
||||
const { piece } = await createPiece({ title: `Lote rota ${Date.now()}` })
|
||||
const call = (body: unknown, origin = 'http://localhost') =>
|
||||
postBatch(
|
||||
new Request('http://localhost/campanha/comunicacao/conteudos/lote', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', origin },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
)
|
||||
|
||||
const published = await call({ action: 'publicar', contentPieceIds: [piece.id] })
|
||||
expect(published.status).toBe(200)
|
||||
await expect(published.json()).resolves.toEqual({
|
||||
status: 'success',
|
||||
outcome: { action: 'publicar', affected: 1, failures: [] },
|
||||
})
|
||||
|
||||
const crossOrigin = await call(
|
||||
{ action: 'publicar', contentPieceIds: [piece.id] },
|
||||
'https://evil.example',
|
||||
)
|
||||
expect(crossOrigin.status).toBe(403)
|
||||
|
||||
getCampaignUserMock.mockResolvedValue(advisor)
|
||||
const denied = await call({ action: 'apagar', contentPieceIds: [piece.id] })
|
||||
expect(denied.status).toBe(400)
|
||||
await expect(denied.json()).resolves.toEqual({
|
||||
status: 'error',
|
||||
message: CONTENT_PIECE_FORBIDDEN_MESSAGE,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('reaps a stale processing row to failed and reports the repaired status', async () => {
|
||||
const { piece } = await createPiece({ processingStatus: 'processando' })
|
||||
await payload.update({
|
||||
|
|
|
|||
|
|
@ -171,6 +171,13 @@ describe('campaign formActions convention', () => {
|
|||
'src/app/(campaign)/campanha/actions/password.ts',
|
||||
'anti-enumeration + bespoke password field errors outside the shared ladder',
|
||||
],
|
||||
// C236 — the batch loop masks one failure per piece into the outcome's
|
||||
// `failures[]` (no form state, no redirect): it is not a form-action
|
||||
// ladder, and the mapping must stay the shared one, not a local copy.
|
||||
[
|
||||
'src/app/(campaign)/campanha/actions/contentPieces.ts',
|
||||
'batch loop maps per-piece failures into the outcome, not a form-action state',
|
||||
],
|
||||
])
|
||||
|
||||
it('routes every hand-rolled mapCampaignFormActionError ladder through a shared wrapper', () => {
|
||||
|
|
|
|||
52
tests/unit/contentPieceBatch.unit.spec.ts
Normal file
52
tests/unit/contentPieceBatch.unit.spec.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
contentPieceBatchDeleteTitle,
|
||||
contentPieceBatchReceiptMessage,
|
||||
contentPieceSelectionLabel,
|
||||
type ContentPieceBatchOutcome,
|
||||
} from '@/lib/contentPieceBatch'
|
||||
|
||||
const outcome = (
|
||||
action: ContentPieceBatchOutcome['action'],
|
||||
affected: number,
|
||||
failed = 0,
|
||||
): ContentPieceBatchOutcome => ({
|
||||
action,
|
||||
affected,
|
||||
failures: Array.from({ length: failed }, (_, index) => ({
|
||||
contentPieceId: index + 1,
|
||||
message: 'Peça não encontrada.',
|
||||
})),
|
||||
})
|
||||
|
||||
describe('contentPieceBatch (C236)', () => {
|
||||
it('writes the all-success receipt in the plan literals, singular included', () => {
|
||||
expect(contentPieceBatchReceiptMessage(outcome('publicar', 3))).toBe('3 peças publicadas')
|
||||
expect(contentPieceBatchReceiptMessage(outcome('despublicar', 1))).toBe('1 peça despublicada')
|
||||
expect(contentPieceBatchReceiptMessage(outcome('apagar', 3))).toBe('3 peças apagadas')
|
||||
expect(contentPieceBatchReceiptMessage(outcome('apagar', 1))).toBe('1 peça apagada')
|
||||
})
|
||||
|
||||
it('never calls a partial outcome a success: it separates affected from failures', () => {
|
||||
expect(contentPieceBatchReceiptMessage(outcome('apagar', 2, 1))).toBe(
|
||||
'2 de 3 peças apagadas. 1 falhou.',
|
||||
)
|
||||
expect(contentPieceBatchReceiptMessage(outcome('publicar', 1, 2))).toBe(
|
||||
'1 de 3 peças publicadas. 2 falharam.',
|
||||
)
|
||||
expect(contentPieceBatchReceiptMessage(outcome('despublicar', 0, 1))).toBe(
|
||||
'0 de 1 peça despublicada. 1 falhou.',
|
||||
)
|
||||
})
|
||||
|
||||
it('counts the selection with the TourComposerForm singular', () => {
|
||||
expect(contentPieceSelectionLabel(1)).toBe('1 selecionada')
|
||||
expect(contentPieceSelectionLabel(3)).toBe('3 selecionadas')
|
||||
})
|
||||
|
||||
it('titles the mass delete and reuses the C222 singular', () => {
|
||||
expect(contentPieceBatchDeleteTitle(1)).toBe('Apagar esta peça?')
|
||||
expect(contentPieceBatchDeleteTitle(3)).toBe('Apagar 3 peças selecionadas?')
|
||||
})
|
||||
})
|
||||
270
tests/unit/contentPieceSelection.unit.spec.tsx
Normal file
270
tests/unit/contentPieceSelection.unit.spec.tsx
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { routerMock } = vi.hoisted(() => ({
|
||||
routerMock: { push: vi.fn(), refresh: vi.fn() },
|
||||
}))
|
||||
|
||||
vi.mock('next/navigation', () => ({ useRouter: () => routerMock }))
|
||||
|
||||
import { ContentPieceBatchReceipt } from '@/components/campaign/content/ContentPieceBatchReceipt'
|
||||
import { ContentPieceSelectionBar } from '@/components/campaign/content/ContentPieceSelectionBar'
|
||||
import {
|
||||
ContentPieceCardSelectionFrame,
|
||||
ContentPieceRowCheckbox,
|
||||
ContentPieceSelectAllCheckbox,
|
||||
ContentPieceSelectionModeControl,
|
||||
} from '@/components/campaign/content/ContentPieceSelectionControls'
|
||||
import { ContentPieceSelectionProvider } from '@/components/campaign/content/ContentPieceSelectionProvider'
|
||||
import { CAMPAIGN_CONTENT_PIECE_BATCH_HREF } from '@/lib/campaignPaths'
|
||||
import type { ContentPieceSelectionRow } from '@/lib/contentPieceBatch'
|
||||
|
||||
const harnessRows = [
|
||||
{ id: 1, title: 'Peça publicada', status: 'publicado', publicPath: '/conteudos/peca-publicada' },
|
||||
{ id: 2, title: 'Peça em rascunho', status: 'rascunho', publicPath: null },
|
||||
{ id: 3, title: 'Peça com falha', status: 'rascunho', publicPath: null },
|
||||
] as const
|
||||
|
||||
const rows: ContentPieceSelectionRow[] = harnessRows.map(({ id, status, publicPath }) => ({
|
||||
id,
|
||||
status,
|
||||
publicPath,
|
||||
}))
|
||||
|
||||
const fetchMock = vi.fn()
|
||||
const jsonResponse = (payload: unknown, ok = true) => ({ ok, json: async () => payload })
|
||||
|
||||
const renderHarness = () => {
|
||||
render(
|
||||
<ContentPieceSelectionProvider rows={rows}>
|
||||
<ContentPieceSelectionModeControl />
|
||||
<ContentPieceBatchReceipt />
|
||||
<ContentPieceSelectAllCheckbox />
|
||||
{harnessRows.map((row) => (
|
||||
<ContentPieceRowCheckbox key={row.id} contentPieceId={row.id} title={row.title} />
|
||||
))}
|
||||
<ContentPieceSelectionBar />
|
||||
</ContentPieceSelectionProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
const enterSelection = () => fireEvent.click(screen.getByRole('button', { name: 'Selecionar' }))
|
||||
|
||||
const check = (name: string) => fireEvent.click(screen.getByRole('checkbox', { name }))
|
||||
|
||||
const press = (name: string) => fireEvent.click(screen.getByRole('button', { name }))
|
||||
|
||||
const openDeleteDialog = async () => {
|
||||
press('Apagar')
|
||||
return screen.findByRole('alertdialog')
|
||||
}
|
||||
|
||||
const selectionStatus = () => screen.getByRole('status').textContent
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
},
|
||||
)
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
routerMock.push.mockReset()
|
||||
routerMock.refresh.mockReset()
|
||||
fetchMock.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('ContentPieceSelection (C236)', () => {
|
||||
it('enters selection mode opt-in, counts the selection and opens the bar only with one', () => {
|
||||
renderHarness()
|
||||
|
||||
expect(screen.queryByText('Modo de seleção')).toBeNull()
|
||||
enterSelection()
|
||||
expect(screen.getByText('Modo de seleção')).toBeDefined()
|
||||
expect(screen.queryByRole('status')).toBeNull()
|
||||
|
||||
check('Selecionar Peça publicada')
|
||||
expect(selectionStatus()).toContain('1 selecionada')
|
||||
|
||||
check('Selecionar Peça em rascunho')
|
||||
expect(selectionStatus()).toContain('2 selecionadas')
|
||||
})
|
||||
|
||||
it('selects and clears the whole visible page from the header checkbox', () => {
|
||||
renderHarness()
|
||||
enterSelection()
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('checkbox', { name: 'Selecionar todas as peças desta página' }),
|
||||
)
|
||||
expect(selectionStatus()).toContain('3 selecionadas')
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('checkbox', { name: 'Selecionar todas as peças desta página' }),
|
||||
)
|
||||
expect(screen.queryByRole('status')).toBeNull()
|
||||
})
|
||||
|
||||
it('publishes the selection in one request, shows the receipt and clears the selection', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse({
|
||||
status: 'success',
|
||||
outcome: { action: 'publicar', affected: 2, failures: [] },
|
||||
}),
|
||||
)
|
||||
renderHarness()
|
||||
enterSelection()
|
||||
check('Selecionar Peça em rascunho')
|
||||
check('Selecionar Peça com falha')
|
||||
|
||||
press('Publicar')
|
||||
|
||||
expect(await screen.findByText('2 peças publicadas')).toBeDefined()
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
CAMPAIGN_CONTENT_PIECE_BATCH_HREF,
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ action: 'publicar', contentPieceIds: [2, 3] }),
|
||||
}),
|
||||
)
|
||||
await waitFor(() => expect(routerMock.refresh).toHaveBeenCalled())
|
||||
expect(screen.queryByText('2 selecionadas')).toBeNull()
|
||||
expect(screen.getByText('Modo de seleção')).toBeDefined()
|
||||
})
|
||||
|
||||
it('keeps the selection and answers in the list when the whole request fails', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: false,
|
||||
json: async () => ({ status: 'error', message: 'Sessão expirada. Entre novamente.' }),
|
||||
})
|
||||
renderHarness()
|
||||
enterSelection()
|
||||
check('Selecionar Peça publicada')
|
||||
|
||||
press('Publicar')
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveProperty(
|
||||
'textContent',
|
||||
expect.stringContaining('Sessão expirada. Entre novamente.'),
|
||||
)
|
||||
expect(selectionStatus()).toContain('1 selecionada')
|
||||
expect(routerMock.refresh).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('warns about the public links only when the selection carries published pieces', async () => {
|
||||
renderHarness()
|
||||
enterSelection()
|
||||
|
||||
check('Selecionar Peça publicada')
|
||||
let dialog = await openDeleteDialog()
|
||||
expect(within(dialog).getByText('Apagar esta peça?')).toBeDefined()
|
||||
expect(within(dialog).getByText('/conteudos/peca-publicada')).toBeDefined()
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Cancelar' }))
|
||||
|
||||
check('Selecionar Peça em rascunho')
|
||||
dialog = await openDeleteDialog()
|
||||
expect(within(dialog).getByText('Apagar 2 peças selecionadas?')).toBeDefined()
|
||||
expect(within(dialog).getByText(/deixam de funcionar para quem já recebeu/)).toBeDefined()
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Cancelar' }))
|
||||
|
||||
check('Selecionar Peça publicada')
|
||||
check('Selecionar Peça com falha')
|
||||
dialog = await openDeleteDialog()
|
||||
expect(within(dialog).getByText('Apagar 2 peças selecionadas?')).toBeDefined()
|
||||
expect(within(dialog).getByText('Esta ação não pode ser desfeita.')).toBeDefined()
|
||||
})
|
||||
|
||||
it('deletes the selection after the confirmation and names the partial failure', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse({
|
||||
status: 'success',
|
||||
outcome: {
|
||||
action: 'apagar',
|
||||
affected: 2,
|
||||
failures: [{ contentPieceId: 3, message: 'Peça não encontrada.' }],
|
||||
},
|
||||
}),
|
||||
)
|
||||
renderHarness()
|
||||
enterSelection()
|
||||
check('Selecionar Peça em rascunho')
|
||||
check('Selecionar Peça com falha')
|
||||
|
||||
const dialog = await openDeleteDialog()
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Apagar' }))
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveProperty(
|
||||
'textContent',
|
||||
expect.stringContaining('2 de 3 peças apagadas. 1 falhou.'),
|
||||
)
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
CAMPAIGN_CONTENT_PIECE_BATCH_HREF,
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ action: 'apagar', contentPieceIds: [2, 3] }),
|
||||
}),
|
||||
)
|
||||
await waitFor(() => expect(routerMock.refresh).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('keeps the delete failure inside the dialog, with the selection intact', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse({ status: 'error', message: 'Peça não encontrada.' }, false),
|
||||
)
|
||||
renderHarness()
|
||||
enterSelection()
|
||||
check('Selecionar Peça publicada')
|
||||
check('Selecionar Peça em rascunho')
|
||||
|
||||
const dialog = await openDeleteDialog()
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Apagar' }))
|
||||
|
||||
expect(await within(dialog).findByText('Peça não encontrada.')).toBeDefined()
|
||||
expect(screen.queryByText('2 de 3 peças apagadas. 1 falhou.')).toBeNull()
|
||||
expect(within(dialog).getByText('Apagar 2 peças selecionadas?')).toBeDefined()
|
||||
expect(routerMock.refresh).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves the selection mode from the top control', () => {
|
||||
renderHarness()
|
||||
enterSelection()
|
||||
check('Selecionar Peça publicada')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancelar seleção' }))
|
||||
|
||||
expect(screen.queryByText('Modo de seleção')).toBeNull()
|
||||
expect(screen.queryByRole('status')).toBeNull()
|
||||
expect(screen.getByRole('button', { name: 'Selecionar' })).toBeDefined()
|
||||
})
|
||||
|
||||
it('selects from the mobile card frame with the one shared state', () => {
|
||||
render(
|
||||
<ContentPieceSelectionProvider rows={rows}>
|
||||
<ContentPieceSelectionModeControl />
|
||||
<ContentPieceCardSelectionFrame
|
||||
contentPieceId={1}
|
||||
title="Peça publicada"
|
||||
header={<p>Peça publicada</p>}
|
||||
>
|
||||
<p>Corpo do card</p>
|
||||
</ContentPieceCardSelectionFrame>
|
||||
<ContentPieceSelectionBar />
|
||||
</ContentPieceSelectionProvider>,
|
||||
)
|
||||
|
||||
// At rest the frame is the exact card, with no checkbox to scan past.
|
||||
expect(screen.queryByRole('checkbox')).toBeNull()
|
||||
expect(screen.getByText('Corpo do card')).toBeDefined()
|
||||
|
||||
enterSelection()
|
||||
check('Selecionar Peça publicada')
|
||||
expect(selectionStatus()).toContain('1 selecionada')
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue