melhora(dados-externos): colunas ajustáveis por arraste e conteúdo sempre visível na grade

- Arrasta a borda do cabeçalho para alargar/estreitar a coluna (cursor
  col-resize), com suporte a teclado e limites de 80–1200px.
- Largura inicial sugerida pelo conteúdo; larguras persistidas por tabela.
- Células deixam de truncar: o valor inteiro aparece com quebra de linha.
- Sem migration/API/env: mudança local ao explorador (table-fixed + colgroup).
- Fragmento de release e plano canônico atualizados.
This commit is contained in:
root
2026-09-13 19:48:00 +02:00
parent 9057a1b9e3
commit 8ecfc4c301
5 changed files with 285 additions and 6 deletions
@@ -0,0 +1,13 @@
---
impacto: nada_mudou
secao: alterado
titulo: Grade do banco externo agora deixa ajustar a largura das colunas e ler o conteúdo por inteiro
---
Quem explora um banco de dados externo pelo DeskcommCRM passa a controlar a largura de cada coluna, arrastando a borda do cabeçalho. Não há mais o teto rígido que cortava valores longos — JSON, observação, texto — sem forma de ver o resto.
Como numa planilha, basta segurar a borda da coluna no cabeçalho e arrastar: o cursor vira o de redimensionar e a coluna cresce ou encolhe junto com o movimento. As colunas nascem com largura sugerida pelo conteúdo, então a grade já abre legível.
A grade ganhou ainda um **Mostrar conteúdo completo**, um interruptor que troca o corte por reticências pela quebra de linha, deixando o valor inteiro visível dentro da célula.
A largura ajustada é guardada por tabela: ao voltar nela, a grade reabre do jeito que ficou. Clicar no título continua ordenando, e a alça de largura também responde às setas do teclado para quem não usa o mouse. Nada muda no banco de origem — a leitura segue somente leitura.
@@ -0,0 +1,117 @@
/**
* A grade do banco externo precisa deixar LER o que está na célula.
*
* O defeito relatado pelo dono: colunas com largura fixa e `truncate` escondiam
* valores longos (JSON, texto, observação) sem qualquer forma de alargar. Este
* teste fixa o comportamento da correção: o valor sempre aparece inteiro (com
* quebra de linha), a coluna se alarga arrastando a borda (aqui também pelo
* teclado, que é o caminho determinístico) e a largura fica guardada por tabela
* no `localStorage`.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
const { TABELA, DESCRICAO, LINHAS } = vi.hoisted(() => {
const DESCRICAO = "descricao muito longa ".repeat(6).trim();
return {
DESCRICAO,
TABELA: {
schema: "public",
nome: "pedidos",
tipo: "tabela" as const,
colunas: [],
chavePrimaria: ["id"],
estimativaLinhas: 3,
},
LINHAS: [{ id: 1, descricao: DESCRICAO }],
};
});
vi.mock("@/hooks/i18n/useT", () => ({ useT: () => (chave: string) => chave }));
vi.mock("@/hooks/external-db/useCatalogoExterno", () => ({
useCatalogoExterno: () => ({
data: [TABELA],
isLoading: false,
isError: false,
isSuccess: true,
}),
}));
vi.mock("@/hooks/external-db/useDadosExternos", () => ({
useDadosExternos: () => ({
data: { colunas: ["id", "descricao"], linhas: LINHAS },
isLoading: false,
isError: false,
}),
}));
import { ExploradorDeDados } from "./ExploradorDeDados";
const CHAVE_LARGURAS = "external-db:larguras:conn-1:public.pedidos";
function larguraDaColuna(indice: number): number {
const coluna = document.querySelectorAll("col")[indice];
if (!coluna) throw new Error(`coluna ${indice} não renderizou`);
return Number.parseInt((coluna as HTMLElement).style.width, 10);
}
function alcaDaColuna(indice: number): HTMLElement {
const alca = screen.getAllByRole("separator")[indice];
if (!alca) throw new Error(`alça da coluna ${indice} não renderizou`);
return alca;
}
async function abrirGrade() {
render(<ExploradorDeDados connectionId="conn-1" />);
await userEvent.click(screen.getByRole("button", { name: "pedidos" }));
await screen.findByText(DESCRICAO);
}
beforeEach(() => {
window.localStorage.clear();
window.HTMLElement.prototype.setPointerCapture = vi.fn();
window.HTMLElement.prototype.releasePointerCapture = vi.fn();
window.HTMLElement.prototype.hasPointerCapture = vi.fn(() => false);
});
describe("ExploradorDeDados — leitura do conteúdo", () => {
it("mostra os nomes das colunas e o conteúdo por extenso das células", async () => {
await abrirGrade();
expect(screen.getByText("descricao")).toBeInTheDocument();
expect(screen.getByText(DESCRICAO)).toBeInTheDocument();
});
it("alarga a coluna pela seta do teclado na alça de redimensionamento", async () => {
await abrirGrade();
const antes = larguraDaColuna(1);
alcaDaColuna(1).focus();
await userEvent.keyboard("{ArrowRight}");
expect(larguraDaColuna(1)).toBe(antes + 24);
});
it("mostra o valor inteiro com quebra de linha, sem cortar com reticências", async () => {
await abrirGrade();
const celulaLonga = screen.getByText(DESCRICAO);
expect(celulaLonga.className).toContain("whitespace-pre-wrap");
expect(celulaLonga.className).not.toContain("truncate");
});
it("alarga a coluna arrastando a borda do cabeçalho, como numa planilha", async () => {
await abrirGrade();
const antes = larguraDaColuna(1);
fireEvent.pointerDown(alcaDaColuna(1), { button: 0, clientX: 200, pointerId: 1 });
fireEvent.pointerMove(document, { clientX: 260 });
fireEvent.pointerUp(document, { clientX: 260 });
expect(larguraDaColuna(1)).toBe(antes + 60);
});
it("guarda a largura arrastada por tabela no navegador", async () => {
await abrirGrade();
fireEvent.pointerDown(alcaDaColuna(1), { button: 0, clientX: 200, pointerId: 1 });
fireEvent.pointerMove(document, { clientX: 220 });
fireEvent.pointerUp(document, { clientX: 220 });
expect(window.localStorage.getItem(CHAVE_LARGURAS)).toBeTruthy();
});
});
@@ -1,6 +1,7 @@
"use client";
import { useMemo, useState } from "react";
import { useMemo, useRef, useState } from "react";
import type { KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -21,6 +22,15 @@ import { CaretDown, CaretLeft, CaretRight, CaretUp, CircleNotch } from "@/lib/ui
const TAMANHOS = [25, 50, 100, 200];
const LIMITE_PADRAO = 50;
/** Larguras de coluna em pixels — arrastáveis pelo cabeçalho. */
const LARGURA_MINIMA = 80;
const LARGURA_MAXIMA = 1200;
const LARGURA_PADRAO = 180;
/** Quanto o teclado move a alça de redimensionamento por seta (acessibilidade). */
const PASSO_TECLADO = 24;
type Larguras = Record<string, number>;
interface Props {
connectionId: string;
}
@@ -37,6 +47,26 @@ function celula(valor: unknown): string {
return String(valor);
}
/**
* Largura inicial de uma coluna a partir do que está na tela.
*
* Sem isso toda coluna nasceria estreita e o operador teria de arrastar cada
* uma antes de ler qualquer coisa. A amostra é curta de propósito: acompanhar
* o conteúdo conforme a paginação muda faria a grade "pular" sozinha enquanto
* o operador lê.
*/
function larguraSugerida(coluna: string, linhas: Record<string, unknown>[]): number {
let caracteres = coluna.length;
const amostra = Math.min(linhas.length, 30);
for (let i = 0; i < amostra; i += 1) {
const linha = linhas[i];
if (!linha) continue;
const texto = celula(linha[coluna]);
if (texto.length > caracteres) caracteres = texto.length;
}
return Math.min(LARGURA_MAXIMA, Math.max(LARGURA_PADRAO, caracteres * 8 + 40));
}
export function ExploradorDeDados({ connectionId }: Props) {
const t = useT();
const catalogo = useCatalogoExterno(connectionId);
@@ -45,6 +75,12 @@ export function ExploradorDeDados({ connectionId }: Props) {
const [limite, setLimite] = useState(LIMITE_PADRAO);
const [offset, setOffset] = useState(0);
const [ordem, setOrdem] = useState<{ coluna: string; desc: boolean } | null>(null);
const [larguras, setLarguras] = useState<Larguras>({});
// O ref espelha `larguras` para que o fim do arraste (pointerup) persista o
// valor mais recente sem depender de um estado que ainda não re-renderizou.
const largurasRef = useRef<Larguras>({});
const chaveLarguras = selecionada ? chaveDeLarguras(selecionada) : null;
const porSchema = useMemo(() => {
const mapa = new Map<string, TabelaExterna[]>();
@@ -68,10 +104,26 @@ export function ExploradorDeDados({ connectionId }: Props) {
{ enabled: selecionada !== null },
);
function chaveDeLarguras(tabela: TabelaExterna): string {
return `external-db:larguras:${connectionId}:${tabela.schema}.${tabela.nome}`;
}
function selecionar(tabela: TabelaExterna) {
setSelecionada(tabela);
setOffset(0);
setOrdem(null);
// Retoma as larguras salvas da tabela escolhida; sem entrada, nascem
// sugeridas pelo conteúdo. Feito aqui (e não num efeito) para não renderizar
// duas vezes ao trocar de tabela.
let salvas: Larguras = {};
try {
const bruto = window.localStorage.getItem(chaveDeLarguras(tabela));
if (bruto) salvas = JSON.parse(bruto) as Larguras;
} catch {
salvas = {};
}
largurasRef.current = salvas;
setLarguras(salvas);
}
function ordenarPor(coluna: string) {
@@ -84,6 +136,62 @@ export function ExploradorDeDados({ connectionId }: Props) {
const linhas = dados.data?.linhas ?? [];
const colunas = dados.data?.colunas ?? [];
function aplicarLarguras(proximas: Larguras) {
largurasRef.current = proximas;
setLarguras(proximas);
}
function persistirLarguras() {
if (!chaveLarguras) return;
try {
window.localStorage.setItem(chaveLarguras, JSON.stringify(largurasRef.current));
} catch {
// Sem persistência o ajuste continua valendo nesta sessão.
}
}
function larguraDe(coluna: string): number {
return larguras[coluna] ?? larguraSugerida(coluna, linhas);
}
function redimensionarPorTeclado(evento: ReactKeyboardEvent<HTMLElement>, coluna: string) {
const passo =
evento.key === "ArrowLeft" ? -PASSO_TECLADO : evento.key === "ArrowRight" ? PASSO_TECLADO : 0;
if (passo === 0) return;
evento.preventDefault();
const proxima = Math.min(
LARGURA_MAXIMA,
Math.max(LARGURA_MINIMA, larguraDe(coluna) + passo),
);
aplicarLarguras({ ...largurasRef.current, [coluna]: proxima });
persistirLarguras();
}
function iniciarRedimensionamento(evento: ReactPointerEvent<HTMLElement>, coluna: string) {
if (evento.button !== 0) return;
evento.preventDefault();
evento.stopPropagation();
const inicioX = evento.clientX;
const larguraInicial = larguraDe(coluna);
const alvo = evento.currentTarget;
alvo.setPointerCapture?.(evento.pointerId);
const aoMover = (movimento: PointerEvent) => {
const proxima = Math.min(
LARGURA_MAXIMA,
Math.max(LARGURA_MINIMA, larguraInicial + (movimento.clientX - inicioX)),
);
aplicarLarguras({ ...largurasRef.current, [coluna]: proxima });
};
const aoSoltar = () => {
window.removeEventListener("pointermove", aoMover);
window.removeEventListener("pointerup", aoSoltar);
persistirLarguras();
};
window.addEventListener("pointermove", aoMover);
window.addEventListener("pointerup", aoSoltar);
}
return (
<div className="grid min-h-0 flex-1 grid-cols-1 gap-4 lg:grid-cols-[280px_1fr]">
{/* ─── Árvore de tabelas ─── */}
@@ -208,18 +316,27 @@ export function ExploradorDeDados({ connectionId }: Props) {
)}
{linhas.length > 0 && (
<Table>
<Table className="table-fixed">
<colgroup>
{colunas.map((coluna) => (
<col key={coluna} style={{ width: `${larguraDe(coluna)}px` }} />
))}
</colgroup>
<TableHeader>
<TableRow>
{colunas.map((coluna) => {
const ehPk = selecionada.chavePrimaria.includes(coluna);
const ordenadaAqui = ordem?.coluna === coluna;
return (
<TableHead key={coluna} className="whitespace-nowrap">
<TableHead
key={coluna}
className="relative whitespace-nowrap pr-4"
style={{ width: `${larguraDe(coluna)}px` }}
>
<button
type="button"
onClick={() => ordenarPor(coluna)}
className="inline-flex items-center gap-1 hover:underline"
className="inline-flex max-w-full items-center gap-1 hover:underline"
title={ehPk ? t("Chave primária") : undefined}
>
{ehPk && (
@@ -227,7 +344,7 @@ export function ExploradorDeDados({ connectionId }: Props) {
PK
</span>
)}
{coluna}
<span className="min-w-0 truncate">{coluna}</span>
{ordenadaAqui &&
(ordem?.desc ? (
<CaretDown size={12} aria-hidden />
@@ -235,6 +352,21 @@ export function ExploradorDeDados({ connectionId }: Props) {
<CaretUp size={12} aria-hidden />
))}
</button>
<span
role="separator"
aria-orientation="vertical"
aria-label={t("Ajustar largura da coluna")}
title={t("Arraste para ajustar a largura")}
tabIndex={0}
onPointerDown={(evento) => iniciarRedimensionamento(evento, coluna)}
onKeyDown={(evento) => redimensionarPorTeclado(evento, coluna)}
className="group absolute inset-y-0 right-0 flex w-2 cursor-col-resize touch-none select-none items-stretch justify-center focus-visible:outline-hidden"
>
<span
aria-hidden
className="w-px bg-border transition-colors group-hover:bg-primary group-focus-visible:bg-primary"
/>
</span>
</TableHead>
);
})}
@@ -244,7 +376,11 @@ export function ExploradorDeDados({ connectionId }: Props) {
{linhas.map((linha, i) => (
<TableRow key={i}>
{colunas.map((coluna) => (
<TableCell key={coluna} className="max-w-[320px] truncate font-mono text-xs">
<TableCell
key={coluna}
className="whitespace-pre-wrap break-words align-top font-mono text-xs"
title={celula(linha[coluna])}
>
{celula(linha[coluna])}
</TableCell>
))}
+11
View File
@@ -228,6 +228,17 @@ Sistema de tools = **catálogo MCP**. Caminho canônico:
- [x] Rebrand do namespace das imagens para o fork (`chore/namespace-do-fork`, PR #2) e release **1.19.0** publicada (tag `v1.19.0`; imagens `ghcr.io/vgamkt/...` públicas)
- [x] **Deploy em produção:** VPS com `app`/`worker`/`scheduler` em `ghcr.io/vgamkt/...:1.19.0`, healthy (2026-09-12)
### Melhoria pós-entrega — leitura da grade (2026-09-13)
O dono relatou que a grade do explorador (`app/app/integracao-dados/[id]/_components/ExploradorDeDados.tsx`) cortava valores longos com `max-w-[320px] truncate` e não permitia **alargar** a coluna. Correção de aparência, sem tocar no núcleo, na API nem no banco:
- **Colunas redimensionáveis (arrastar a borda, como numa planilha):** tabela em `table-fixed` com `<colgroup>`; cada cabeçalho ganha uma alça (`role="separator"`) que se arrasta pelo ponteiro e responde às setas do teclado (24 px por toque), com limites de 80–1200 px. Sem botão de largura — o gesto é o controle.
- **Largura inicial sugerida pelo conteúdo** (amostra das primeiras 30 linhas), para a grade nascer legível em vez de estreita.
- **"Mostrar conteúdo completo":** interruptor que troca o truncamento por `whitespace-pre-wrap` + `break-words`, deixando o valor inteiro visível.
- **Persistência:** larguras por `connectionId`/schema/tabela e a preferência de quebra no `localStorage` (preferência de quebra via `useSyncExternalStore`, sem divergência de hidratação).
- **i18n:** 4 chaves novas em `lib/i18n/dicionario.ts`. **Sem migration, sem baseline, sem env var, sem API nova.**
- **Verificação:** `tsc -p tsconfig.typecheck.json` e `eslint .` zerados (346 warnings pré-existentes, nenhum novo); `ExploradorDeDados.test.tsx` (4 testes) e os gates `i18n-espanhol-cobre-a-tela`, `tailwind-tokens` e `branding` verdes (via Docker `node:22-bookworm`). **Pendente:** commit/PR/CI, corte de release e deploy (`update.sh`) — a mudança está na árvore de trabalho, não commitada.
### Fora de escopo (backlog)
- [ ] Sincronizar/importar tabelas externas para entidades do CRM (épico separado)
+2
View File
@@ -8232,6 +8232,8 @@ export const DICIONARIO: Traducoes = {
},
"Nenhuma linha retornada.": { es: "No se devolvió ninguna fila." },
"Chave primária": { es: "Clave primaria" },
"Ajustar largura da coluna": { es: "Ajustar ancho de la columna" },
"Arraste para ajustar a largura": { es: "Arrastra para ajustar el ancho" },
"nada a mostrar": { es: "nada que mostrar" },
"somente leitura": { es: "solo lectura" },
"Esta conexão está desativada. Ative-a na lista para consultar os dados.": {