fix(editor): hide commit/rollback for clean mysql/pg manual transactions
Closes #9018
This commit is contained in:
1 parent
91b8fa0aa8
commit
e9be6d0419
15 files changed
+1016
-137
No files matched your search
@@ -13,7 +13,7 @@ import { createContentSurfaceEventForwarders } from "@/lib/tabs/contentSurfaceEv
|
||||
import { isPreviewTab } from "@/lib/tabs/tabPresentation";
|
||||
import { resolveExecutableSql } from "@/lib/sql/sqlExecutionTarget";
|
||||
import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
|
||||
import { usesOracleStickyTransactionState } from "@/lib/database/databaseFeatureSupport";
|
||||
import { usesProvenReadOnlyStickyTransactionState } from "@/lib/database/databaseFeatureSupport";
|
||||
import { GROUP_TAB_BAR_PORTAL } from "./groupTabBarPortal";
|
||||
import type { ContentAreaSurfaceEmits, ContentAreaSurfaceProps, QueryEditorSurfaceHandle, StatementRange } from "./querySurfaces";
|
||||
import type { QueryTab } from "@/types/database";
|
||||
@@ -110,7 +110,7 @@ const groupTabs = computed(() => {
|
||||
const activeTab = computed(() => groupTabs.value.find((tab) => tab.id === props.activeTabId) ?? groupTabs.value[0] ?? null);
|
||||
const activeConnection = computed(() => (activeTab.value ? connectionStore.getConfig(activeTab.value.connectionId) : undefined));
|
||||
const showGroupToolbar = computed(() => activeTab.value?.mode === "query" && !isPreviewTab(activeTab.value));
|
||||
const isGroupOracleManualTransaction = computed(() => usesOracleStickyTransactionState(effectiveDatabaseTypeForConnection(activeConnection.value)) && (activeTab.value?.autoCommit ?? true) === false);
|
||||
const isGroupStickyManualTransaction = computed(() => usesProvenReadOnlyStickyTransactionState(effectiveDatabaseTypeForConnection(activeConnection.value)) && (activeTab.value?.autoCommit ?? true) === false);
|
||||
// Each group previews the executable SQL of its own active tab (selection
|
||||
// stored on the tab), not the focused tab's global selection.
|
||||
// tabPlacement drives each pane's own bar position: the strip sits above,
|
||||
@@ -185,8 +185,8 @@ const groupExecutableSql = computed(() => {
|
||||
:auto-commit="activeTab.autoCommit ?? true"
|
||||
:txn-session-id="activeTab.txnSessionId"
|
||||
:txn-auto-rolled-back="activeTab.txnAutoRolledBack"
|
||||
:oracle-txn-possibly-dirty="activeTab.oracleTxnPossiblyDirty"
|
||||
:is-oracle-manual-transaction="isGroupOracleManualTransaction"
|
||||
:txn-possibly-dirty="activeTab.txnPossiblyDirty"
|
||||
:sticky-proven-read-only-state="isGroupStickyManualTransaction"
|
||||
@update:explain-mode="(m: 'explain' | 'autotrace') => (toolbar.explainMode.value = m)"
|
||||
@update:block-dangerous-redis-commands="(v: boolean) => (toolbar.blockDangerousRedisCommands.value = v)"
|
||||
@update:auto-commit="
|
||||
|
||||
@@ -43,10 +43,10 @@ const props = defineProps<{
|
||||
txnAutoRolledBack?: boolean;
|
||||
/** Oracle-only: whether the current manual Oracle session executed a statement
|
||||
* DBX cannot prove read-only. Commit/Rollback are hidden while false. */
|
||||
oracleTxnPossiblyDirty?: boolean;
|
||||
txnPossiblyDirty?: boolean;
|
||||
/** Oracle manual mode derived from the resolved database type (not raw
|
||||
* db_type, which can be the agent transport). */
|
||||
isOracleManualTransaction?: boolean;
|
||||
stickyProvenReadOnlyState?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -272,10 +272,11 @@ function toggleInsertValueHints() {
|
||||
const isTransactionActive = computed(() => !!props.txnSessionId);
|
||||
const isManualTransactionMode = computed(() => props.autoCommit === false || isTransactionActive.value);
|
||||
const transactionModeBadge = computed(() => (isManualTransactionMode.value ? "M" : "A"));
|
||||
// Oracle manual mode hides Commit/Rollback while the session is clean (no
|
||||
// unproven statement executed). Every other database keeps the existing rule.
|
||||
// Sticky proven-read-only dialects (Oracle/OceanBase-Oracle/MySQL/PostgreSQL)
|
||||
// hide Commit/Rollback while the session is clean (no unproven statement
|
||||
// executed). Every other database keeps the existing rule.
|
||||
const showTxnActions = computed(() => {
|
||||
if (props.isOracleManualTransaction) return isTransactionActive.value && props.oracleTxnPossiblyDirty === true;
|
||||
if (props.stickyProvenReadOnlyState) return isTransactionActive.value && props.txnPossiblyDirty === true;
|
||||
return isTransactionActive.value;
|
||||
});
|
||||
const transactionTooltip = computed(() => {
|
||||
|
||||
@@ -122,8 +122,8 @@ describe("EditorToolbar mount contract", () => {
|
||||
autoCommit: true,
|
||||
txnSessionId: undefined,
|
||||
txnAutoRolledBack: false,
|
||||
oracleTxnPossiblyDirty: false,
|
||||
isOracleManualTransaction: false,
|
||||
txnPossiblyDirty: false,
|
||||
stickyProvenReadOnlyState: false,
|
||||
onToolbarExecute,
|
||||
});
|
||||
app.use(pinia);
|
||||
@@ -195,8 +195,8 @@ describe("EditorToolbar mount contract", () => {
|
||||
autoCommit: true,
|
||||
txnSessionId: undefined,
|
||||
txnAutoRolledBack: false,
|
||||
oracleTxnPossiblyDirty: false,
|
||||
isOracleManualTransaction: false,
|
||||
txnPossiblyDirty: false,
|
||||
stickyProvenReadOnlyState: false,
|
||||
onExecutePointerDown,
|
||||
onToolbarExecute,
|
||||
});
|
||||
@@ -274,8 +274,8 @@ describe("EditorToolbar mount contract", () => {
|
||||
autoCommit: true,
|
||||
txnSessionId: undefined,
|
||||
txnAutoRolledBack: false,
|
||||
oracleTxnPossiblyDirty: false,
|
||||
isOracleManualTransaction: false,
|
||||
txnPossiblyDirty: false,
|
||||
stickyProvenReadOnlyState: false,
|
||||
onExecutePointerDown: () => {
|
||||
pendingSnapshot = editorRef.value?.captureExecutionSnapshot?.();
|
||||
},
|
||||
@@ -341,8 +341,8 @@ describe("EditorToolbar mount contract", () => {
|
||||
autoCommit: true,
|
||||
txnSessionId: undefined,
|
||||
txnAutoRolledBack: false,
|
||||
oracleTxnPossiblyDirty: false,
|
||||
isOracleManualTransaction: false,
|
||||
txnPossiblyDirty: false,
|
||||
stickyProvenReadOnlyState: false,
|
||||
});
|
||||
app.use(pinia);
|
||||
app.use(i18n);
|
||||
@@ -396,8 +396,8 @@ describe("EditorToolbar mount contract", () => {
|
||||
autoCommit: true,
|
||||
txnSessionId: undefined,
|
||||
txnAutoRolledBack: false,
|
||||
oracleTxnPossiblyDirty: false,
|
||||
isOracleManualTransaction: false,
|
||||
txnPossiblyDirty: false,
|
||||
stickyProvenReadOnlyState: false,
|
||||
});
|
||||
app.config.errorHandler = (err) => {
|
||||
errors.push(err);
|
||||
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { createApp, nextTick } from "vue";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { createI18n } from "vue-i18n";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/components/ui/button", () => ({
|
||||
Button: {
|
||||
name: "ButtonStub",
|
||||
template: `<button><slot /></button>`,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/searchable-select", () => ({
|
||||
SearchableSelect: {
|
||||
name: "SearchableSelectStub",
|
||||
template: `<div />`,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/tooltip", () => ({
|
||||
Tooltip: {
|
||||
name: "TooltipStub",
|
||||
template: `<span><slot /></span>`,
|
||||
},
|
||||
TooltipTrigger: {
|
||||
name: "TooltipTriggerStub",
|
||||
template: `<span><slot /></span>`,
|
||||
},
|
||||
TooltipContent: {
|
||||
name: "TooltipContentStub",
|
||||
template: `<span><slot /></span>`,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/TruncatedTextTooltip.vue", () => ({
|
||||
default: {
|
||||
name: "TruncatedTextTooltipStub",
|
||||
template: `<span />`,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/icons/DatabaseIcon.vue", () => ({
|
||||
default: {
|
||||
name: "DatabaseIconStub",
|
||||
template: `<span />`,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/connection/ConnectionTreeSelect.vue", () => ({
|
||||
default: {
|
||||
name: "ConnectionTreeSelectStub",
|
||||
template: `<div />`,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/common/ProductionContextBadge.vue", () => ({
|
||||
default: {
|
||||
name: "ProductionContextBadgeStub",
|
||||
template: `<span />`,
|
||||
},
|
||||
}));
|
||||
|
||||
import EditorToolbar from "../EditorToolbar.vue";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
|
||||
function createHost(): HTMLDivElement {
|
||||
const host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
return host;
|
||||
}
|
||||
|
||||
describe("EditorToolbar commit/rollback visibility", () => {
|
||||
let pinia: ReturnType<typeof createPinia>;
|
||||
let i18n: ReturnType<typeof createI18n>;
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: "en",
|
||||
messages: { en: {} },
|
||||
});
|
||||
});
|
||||
|
||||
async function mountToolbar(props: { dbType: string; stickyProvenReadOnlyState: boolean; txnPossiblyDirty?: boolean; txnSessionId?: string }) {
|
||||
const connectionStore = useConnectionStore();
|
||||
connectionStore.connections = [
|
||||
{
|
||||
id: "conn-1",
|
||||
name: "conn",
|
||||
db_type: props.dbType,
|
||||
color: "",
|
||||
} as never,
|
||||
];
|
||||
|
||||
const host = createHost();
|
||||
const app = createApp(EditorToolbar, {
|
||||
activeTab: {
|
||||
id: "tab-1",
|
||||
title: "SQL",
|
||||
connectionId: "conn-1",
|
||||
database: "db",
|
||||
sql: "SELECT 1",
|
||||
mode: "query",
|
||||
isExecuting: false,
|
||||
isCancelling: false,
|
||||
isExplaining: false,
|
||||
},
|
||||
activeConnection: connectionStore.getConfig("conn-1"),
|
||||
executableSql: "SELECT 1",
|
||||
explainMode: "explain",
|
||||
blockDangerousRedisCommands: false,
|
||||
sqlKeywordCase: "preserve",
|
||||
databaseRequiredSignal: 0,
|
||||
autoCommit: false,
|
||||
txnSessionId: props.txnSessionId,
|
||||
txnAutoRolledBack: false,
|
||||
txnPossiblyDirty: props.txnPossiblyDirty,
|
||||
stickyProvenReadOnlyState: props.stickyProvenReadOnlyState,
|
||||
});
|
||||
app.use(pinia);
|
||||
app.use(i18n);
|
||||
app.mount(host);
|
||||
await nextTick();
|
||||
return host;
|
||||
}
|
||||
|
||||
const commitSelector = `button[aria-label="toolbar.commit"]`;
|
||||
const rollbackSelector = `button[aria-label="toolbar.rollback"]`;
|
||||
|
||||
it("hides Commit/Rollback for a clean MySQL sticky manual session", async () => {
|
||||
const host = await mountToolbar({ dbType: "mysql", stickyProvenReadOnlyState: true, txnPossiblyDirty: false, txnSessionId: "txn-1" });
|
||||
|
||||
expect(host.querySelector(commitSelector)).toBeNull();
|
||||
expect(host.querySelector(rollbackSelector)).toBeNull();
|
||||
host.remove();
|
||||
});
|
||||
|
||||
it("shows Commit/Rollback once the MySQL sticky session is dirty", async () => {
|
||||
const host = await mountToolbar({ dbType: "mysql", stickyProvenReadOnlyState: true, txnPossiblyDirty: true, txnSessionId: "txn-1" });
|
||||
|
||||
expect(host.querySelector(commitSelector)).not.toBeNull();
|
||||
expect(host.querySelector(rollbackSelector)).not.toBeNull();
|
||||
host.remove();
|
||||
});
|
||||
|
||||
it("keeps the legacy always-visible rule for non-sticky dialects with a session", async () => {
|
||||
const host = await mountToolbar({ dbType: "jdbc", stickyProvenReadOnlyState: false, txnPossiblyDirty: false, txnSessionId: "txn-1" });
|
||||
|
||||
expect(host.querySelector(commitSelector)).not.toBeNull();
|
||||
expect(host.querySelector(rollbackSelector)).not.toBeNull();
|
||||
host.remove();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user