fix(ui): scroll long error messages in rename dialogs
This commit is contained in:
1 parent
20f312d650
commit
d421bd33fc
6 files changed
+97
-14
No files matched your search
@@ -3856,8 +3856,8 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
|
||||
</DialogHeader>
|
||||
<div class="grid gap-3">
|
||||
<Input v-model="renameInput" :placeholder="t('contextMenu.renameObjectNamePlaceholder')" @keydown.enter.prevent="confirmRename" />
|
||||
<pre v-if="renamePreviewSqlText" class="max-h-32 overflow-auto rounded bg-muted p-3 text-xs whitespace-pre-wrap" v-html="highlight(renamePreviewSqlText)"></pre>
|
||||
<p v-if="renameError" class="text-sm text-destructive">{{ renameError }}</p>
|
||||
<pre v-if="renamePreviewSqlText" class="max-h-32 min-w-0 max-w-full overflow-auto rounded bg-muted p-3 text-xs whitespace-pre-wrap" v-html="highlight(renamePreviewSqlText)"></pre>
|
||||
<p v-if="renameError" class="min-w-0 max-w-full overflow-x-auto text-sm text-destructive">{{ renameError }}</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="showRenameDialog = false">{{ t("dangerDialog.cancel") }}</Button>
|
||||
|
||||
@@ -279,8 +279,8 @@ watch(
|
||||
</DialogHeader>
|
||||
<div class="grid gap-3">
|
||||
<Input v-model="renameObjectName" :placeholder="t('contextMenu.renameObjectNamePlaceholder')" @keydown.enter.prevent="confirmRenameObject" />
|
||||
<pre v-if="renameObjectPreviewSql" class="max-h-32 overflow-auto rounded bg-muted p-3 text-xs whitespace-pre-wrap" v-html="highlight(renameObjectPreviewSql)"></pre>
|
||||
<p v-if="renameObjectError" class="text-sm text-destructive">{{ renameObjectError }}</p>
|
||||
<pre v-if="renameObjectPreviewSql" class="max-h-32 min-w-0 max-w-full overflow-auto rounded bg-muted p-3 text-xs whitespace-pre-wrap" v-html="highlight(renameObjectPreviewSql)"></pre>
|
||||
<p v-if="renameObjectError" class="min-w-0 max-w-full overflow-x-auto text-sm text-destructive">{{ renameObjectError }}</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="showRenameObjectDialog = false">{{ t("dangerDialog.cancel") }}</Button>
|
||||
@@ -298,8 +298,8 @@ watch(
|
||||
</DialogHeader>
|
||||
<div class="grid gap-3">
|
||||
<Input v-model="renameMongoCollectionName" :placeholder="t('contextMenu.renameObjectNamePlaceholder')" :disabled="renameMongoCollectionLoading" @keydown.enter.prevent="confirmRenameMongoCollection" />
|
||||
<pre v-if="renameMongoCollectionPreview" class="max-h-32 overflow-auto rounded bg-muted p-3 text-xs whitespace-pre-wrap">{{ renameMongoCollectionPreview }}</pre>
|
||||
<p v-if="renameMongoCollectionError" class="text-sm text-destructive">{{ renameMongoCollectionError }}</p>
|
||||
<pre v-if="renameMongoCollectionPreview" class="max-h-32 min-w-0 max-w-full overflow-auto rounded bg-muted p-3 text-xs whitespace-pre-wrap">{{ renameMongoCollectionPreview }}</pre>
|
||||
<p v-if="renameMongoCollectionError" class="min-w-0 max-w-full overflow-x-auto text-sm text-destructive">{{ renameMongoCollectionError }}</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" :disabled="renameMongoCollectionLoading" @click="showRenameMongoCollectionDialog = false">{{ t("dangerDialog.cancel") }}</Button>
|
||||
|
||||
@@ -77,10 +77,20 @@ async function copy() {
|
||||
|
||||
<!-- centered: 居中占满 -->
|
||||
<div v-else class="flex-1 min-h-0 flex flex-col items-center justify-center gap-2 px-6 py-4 text-center">
|
||||
<TriangleAlert class="h-8 w-8 text-destructive/50" aria-hidden="true" />
|
||||
<div data-native-clipboard class="min-h-0 max-h-48 max-w-lg overflow-auto space-y-1 select-text text-destructive" @mousedown.stop @click.stop>
|
||||
<TriangleAlert class="h-8 w-8 shrink-0 text-destructive/50" aria-hidden="true" />
|
||||
<!--
|
||||
The message box must NOT carry a fixed max-height cap: every ancestor up
|
||||
the results pane is overflow-hidden (DataGrid root/cell, splitpanes pane),
|
||||
so this box is the only scroll surface and the flex chain
|
||||
(flex-1 min-h-0) is what bounds it to the pane height. A fixed rem cap
|
||||
clips long error text with an unreachable scrollbar instead — see issue
|
||||
#7960. min-h-0 is the vertical shrink enabler; min-w-0 lets long
|
||||
unspaced backend tokens wrap instead of widening this centered child.
|
||||
Do not remove either.
|
||||
-->
|
||||
<div data-native-clipboard class="min-h-0 min-w-0 max-w-lg overflow-y-auto space-y-1 select-text text-destructive" @mousedown.stop @click.stop>
|
||||
<div class="text-sm font-medium">{{ displayTitle }}</div>
|
||||
<div class="text-xs break-all cursor-text text-destructive/80 select-text">{{ message }}</div>
|
||||
<div class="text-xs whitespace-pre-wrap break-words text-left cursor-text text-destructive/80 select-text">{{ message }}</div>
|
||||
</div>
|
||||
<div class="shrink-0 flex flex-wrap items-center justify-center gap-2 text-foreground">
|
||||
<Button variant="outline" size="sm" class="h-7 gap-1.5 px-2 text-xs" @click.stop="copy">
|
||||
|
||||
@@ -14,6 +14,14 @@ function findNativeClipboardRegion(container: HTMLElement, text: string): HTMLEl
|
||||
return region;
|
||||
}
|
||||
|
||||
// The centered variant's clipboard region wraps title + message in one node,
|
||||
// so match by containment instead of exact text.
|
||||
function findClipboardRegionContaining(container: HTMLElement, text: string): HTMLElement {
|
||||
const region = Array.from(container.querySelectorAll<HTMLElement>("[data-native-clipboard]")).find((element) => element.textContent?.includes(text));
|
||||
if (!region) throw new Error(`Native clipboard region not found containing: ${text}`);
|
||||
return region;
|
||||
}
|
||||
|
||||
function selectRegion(region: HTMLElement) {
|
||||
const selection = window.getSelection();
|
||||
const range = document.createRange();
|
||||
@@ -22,15 +30,15 @@ function selectRegion(region: HTMLElement) {
|
||||
selection?.addRange(range);
|
||||
}
|
||||
|
||||
async function mountCardBanner() {
|
||||
async function mountBanner(variant: "card" | "centered", message: string) {
|
||||
const container = document.createElement("div");
|
||||
mountedContainers.push(container);
|
||||
document.body.append(container);
|
||||
|
||||
const app = createApp(ErrorBanner, {
|
||||
variant: "card",
|
||||
variant,
|
||||
title: "Save failed",
|
||||
message: "Conditional request failed",
|
||||
message,
|
||||
});
|
||||
app.use(
|
||||
createI18n({
|
||||
@@ -53,7 +61,7 @@ afterEach(() => {
|
||||
|
||||
describe("ErrorBanner native clipboard selection", () => {
|
||||
it.each(["Save failed", "Conditional request failed"])("preserves native copy for selected %s text", async (text) => {
|
||||
const container = await mountCardBanner();
|
||||
const container = await mountBanner("card", "Conditional request failed");
|
||||
selectRegion(findNativeClipboardRegion(container, text));
|
||||
|
||||
expect(
|
||||
@@ -65,8 +73,21 @@ describe("ErrorBanner native clipboard selection", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves native copy for selected centered error message", async () => {
|
||||
const container = await mountBanner("centered", "fatal error\n\nlong detail line");
|
||||
selectRegion(findClipboardRegionContaining(container, "long detail line"));
|
||||
|
||||
expect(
|
||||
eventTargetAllowsNativeClipboard({
|
||||
key: "c",
|
||||
metaKey: true,
|
||||
target: container,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves grid copy shortcuts active without a text selection", async () => {
|
||||
const container = await mountCardBanner();
|
||||
const container = await mountBanner("card", "Conditional request failed");
|
||||
|
||||
expect(
|
||||
eventTargetAllowsNativeClipboard({
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// Source-contract guard for the ErrorBanner "centered" variant (the query
|
||||
// execution error panel rendered by DataGrid).
|
||||
|
||||
// Overview: the centered banner is the only scroll surface for execution
|
||||
// errors — every ancestor up the results pane is overflow-hidden (DataGrid
|
||||
// root and content cell, splitpanes pane), so the pane height must reach the
|
||||
// message box through the flex chain (banner root `flex-1 min-h-0`). A fixed
|
||||
// `max-h-*` cap on the message box breaks that chain: long error text gets
|
||||
// clipped at the cap, and in a short pane the tiny internal scrollbar is
|
||||
// clipped out of view entirely — see #7960 (错误信息面板无法展示完整错误信息).
|
||||
//
|
||||
// These guarantees are what stop that from re-happening:
|
||||
// 1. The message box keeps `min-h-0` (the flex-shrink enabler) and
|
||||
// `overflow-y-auto` (the internal scroller) with NO fixed max-height.
|
||||
// 2. The message text keeps `whitespace-pre-wrap break-words`: backend
|
||||
// errors are multi-line ("summary\n\ndetail"); `break-all` collapsed
|
||||
// those newlines into a dense wall of text, and without wrap guards a
|
||||
// long token could force horizontal overflow.
|
||||
// If either is removed, the clipping regresses. Keep these assertions updated
|
||||
// alongside the component whenever the class strings are touched.
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const errorBannerSource = readFileSync(new URL("../ErrorBanner.vue", import.meta.url), "utf8");
|
||||
|
||||
describe("ErrorBanner centered layout guard", () => {
|
||||
it("bounds the centered message box by the flex chain, not a fixed max-height", () => {
|
||||
// The box must stay shrinkable in both axes (`min-h-0 min-w-0`) and scroll internally
|
||||
// (`overflow-y-auto`); a fixed `max-h-48` cap re-introduces #7960.
|
||||
expect(errorBannerSource).toContain('class="min-h-0 min-w-0 max-w-lg overflow-y-auto space-y-1 select-text text-destructive"');
|
||||
expect(errorBannerSource).not.toContain("max-h-48");
|
||||
});
|
||||
|
||||
it("preserves backend error newlines and wraps long tokens", () => {
|
||||
// `text-left` keeps multi-line errors scannable under the centered
|
||||
// variant's inherited `text-center` (single-line messages are unaffected:
|
||||
// the box hugs its content).
|
||||
expect(errorBannerSource).toContain("text-xs whitespace-pre-wrap break-words text-left cursor-text");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user