mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 01:28:34 +08:00
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:
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.": {
|
||||
|
||||
Reference in New Issue
Block a user