feat(hbase): improve desktop connections and table operations
This commit is contained in:
17 files changed
+514
-50
No files matched your search
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" role="img" aria-label="Apache HBase">
|
||||
<path fill="#ba160c" d="M2 2h5v7h10V2h5v20h-5v-8H7v8H2z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 167 B |
@@ -299,6 +299,7 @@ interface DataGridProps {
|
||||
totalRowCount?: number;
|
||||
totalRowCountIsExact?: boolean;
|
||||
paginationTotalRowCount?: number;
|
||||
paginationEnabled?: boolean;
|
||||
totalRowCountLoading?: boolean;
|
||||
loading?: boolean;
|
||||
cacheKey?: string;
|
||||
@@ -319,6 +320,7 @@ const props = withDefaults(defineProps<DataGridProps>(), {
|
||||
// Vue casts absent Boolean props to false unless a default is explicit.
|
||||
// Regular grids have exact totals; document stores opt into lower-bound totals.
|
||||
totalRowCountIsExact: true,
|
||||
paginationEnabled: true,
|
||||
// Omitted row-action limits must keep normal table-data editing.
|
||||
allowInsertRows: undefined,
|
||||
allowDeleteRows: undefined,
|
||||
@@ -461,7 +463,7 @@ const compactColumnHeaderActions = computed(() => settingsStore.editorSettings.c
|
||||
const dataGridRenderMode = computed(() => settingsStore.editorSettings.dataGridRenderMode);
|
||||
const dataGridSearchMode = computed(() => settingsStore.editorSettings.dataGridSearchMode);
|
||||
const compactDataGridToolbar = computed(() => dataGridTopbarWidth.value > 0 && dataGridTopbarWidth.value < DATA_GRID_COMPACT_TOPBAR_WIDTH);
|
||||
const infiniteScrollEnabled = computed(() => settingsStore.editorSettings.infiniteScroll);
|
||||
const infiniteScrollEnabled = computed(() => props.paginationEnabled && settingsStore.editorSettings.infiniteScroll);
|
||||
const infiniteScrollMaxRows = computed(() => settingsStore.editorSettings.infiniteScrollMaxRows);
|
||||
const expandedCellEditor = ref<{ rowId: number; col: number } | null>(null);
|
||||
|
||||
@@ -9178,6 +9180,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
||||
|
||||
<DataGridPagination
|
||||
v-model:custom-page-size-input="customPageSizeInput"
|
||||
:pagination-enabled="paginationEnabled"
|
||||
:selection-summary="selectionSummary"
|
||||
:selection-summary-sum-text="selectionSummarySumText"
|
||||
:loading="loading"
|
||||
|
||||
@@ -10,19 +10,23 @@ import DataGridExportMenu from "@/components/grid/DataGridExportMenu.vue";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
defineProps<{
|
||||
selectionSummary: { cellCount: number; rowCount: number } | null;
|
||||
selectionSummarySumText: string;
|
||||
loading: boolean;
|
||||
infiniteScrollEnabled: boolean;
|
||||
infiniteScrollAllLoaded: boolean;
|
||||
pageSize: number;
|
||||
pageSizeMenuItems: LightDropdownItem[];
|
||||
exportMenuItems: LightDropdownItem[];
|
||||
currentPage: number;
|
||||
canGoNextPage: boolean;
|
||||
canJumpLastPage: boolean;
|
||||
}>();
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
paginationEnabled?: boolean;
|
||||
selectionSummary: { cellCount: number; rowCount: number } | null;
|
||||
selectionSummarySumText: string;
|
||||
loading: boolean;
|
||||
infiniteScrollEnabled: boolean;
|
||||
infiniteScrollAllLoaded: boolean;
|
||||
pageSize: number;
|
||||
pageSizeMenuItems: LightDropdownItem[];
|
||||
exportMenuItems: LightDropdownItem[];
|
||||
currentPage: number;
|
||||
canGoNextPage: boolean;
|
||||
canJumpLastPage: boolean;
|
||||
}>(),
|
||||
{ paginationEnabled: true },
|
||||
);
|
||||
|
||||
const customPageSizeInput = defineModel<string>("customPageSizeInput", { default: "" });
|
||||
|
||||
@@ -47,10 +51,10 @@ const emit = defineEmits<{
|
||||
</div>
|
||||
</div>
|
||||
<Loader2 v-if="loading" class="w-3 h-3 animate-spin text-muted-foreground" />
|
||||
<template v-if="infiniteScrollEnabled">
|
||||
<template v-if="paginationEnabled && infiniteScrollEnabled">
|
||||
<span v-if="infiniteScrollAllLoaded" class="text-xs text-muted-foreground shrink-0">{{ t("grid.allLoaded") }}</span>
|
||||
</template>
|
||||
<template v-if="!infiniteScrollEnabled">
|
||||
<template v-if="paginationEnabled && !infiniteScrollEnabled">
|
||||
<LightDropdown
|
||||
:model-value="String(pageSize)"
|
||||
:items="pageSizeMenuItems"
|
||||
|
||||
@@ -213,6 +213,26 @@ describe("DataGridPagination", () => {
|
||||
expect(nextPage).toHaveBeenCalledOnce();
|
||||
expect(lastPage).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("hides pagination controls when the data source does not support paging", () => {
|
||||
const mounted = mountComponent(DataGridPagination, {
|
||||
paginationEnabled: false,
|
||||
selectionSummary: null,
|
||||
selectionSummarySumText: "",
|
||||
loading: false,
|
||||
infiniteScrollEnabled: false,
|
||||
infiniteScrollAllLoaded: false,
|
||||
pageSize: 100,
|
||||
customPageSizeInput: "",
|
||||
pageSizeMenuItems: [],
|
||||
exportMenuItems: [],
|
||||
currentPage: 1,
|
||||
canGoNextPage: false,
|
||||
canJumpLastPage: false,
|
||||
});
|
||||
|
||||
expect(findAll(mounted.root, (node) => node.props["data-stub"] === "Button" && node.props.class === "h-5 w-5 shrink-0")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DataGridColumnHeader", () => {
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useToast } from "@/composables/useToast";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import type { CellValue } from "@/lib/dataGrid/cellValue";
|
||||
import { encodeHBaseTextInput, hbaseCellInput } from "@/lib/hbase/hbaseValues";
|
||||
import { loadHBaseRowLimit, saveHBaseRowLimit } from "@/lib/hbase/hbaseBrowserPreferences";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useQueryStore } from "@/stores/queryStore";
|
||||
import type { QueryResult } from "@/types/database";
|
||||
@@ -40,7 +41,7 @@ const loading = ref(false);
|
||||
const error = ref("");
|
||||
const lookupMode = ref<LookupMode>("prefix");
|
||||
const rowKeyInput = ref("");
|
||||
const rowLimit = ref("100");
|
||||
const rowLimit = ref(loadHBaseRowLimit());
|
||||
const truncated = ref(false);
|
||||
const elapsedMs = ref(0);
|
||||
const schema = ref<HBaseTableSchema>();
|
||||
@@ -123,6 +124,10 @@ watch(
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(rowLimit, (value) => {
|
||||
saveHBaseRowLimit(value);
|
||||
});
|
||||
|
||||
async function refreshRows() {
|
||||
if (loading.value || !hasTable.value) return;
|
||||
loading.value = true;
|
||||
@@ -229,7 +234,7 @@ async function createTable() {
|
||||
await api.hbaseCreateTable(props.connectionId, props.namespace, createdTable, families);
|
||||
createTableDialogOpen.value = false;
|
||||
toast(t("hbase.tableCreated", { table: createdTable }));
|
||||
await connectionStore.loadTables(props.connectionId, props.namespace);
|
||||
await connectionStore.refreshObjectListTreeNode(props.connectionId, props.namespace);
|
||||
if (!hasTable.value) {
|
||||
const tab = queryStore.tabs.find((candidate) => candidate.id === props.tabId);
|
||||
if (tab) tab.title = createdTable;
|
||||
@@ -249,7 +254,7 @@ async function deleteTable() {
|
||||
try {
|
||||
await api.hbaseDeleteTable(props.connectionId, props.namespace, props.table);
|
||||
deleteTableDialogOpen.value = false;
|
||||
await connectionStore.loadTables(props.connectionId, props.namespace);
|
||||
await connectionStore.refreshObjectListTreeNode(props.connectionId, props.namespace);
|
||||
queryStore.closeTab(props.tabId);
|
||||
toast(t("hbase.tableDeleted", { table: qualifiedTableLabel.value }));
|
||||
} catch (caught) {
|
||||
@@ -418,6 +423,7 @@ function errorMessage(value: unknown): string {
|
||||
:allow-delete-rows="!readOnly"
|
||||
:loading="loading"
|
||||
:page-limit="Number(rowLimit)"
|
||||
:pagination-enabled="false"
|
||||
:total-row-count="rows.length"
|
||||
:total-row-count-is-exact="!truncated"
|
||||
@reload="refreshRows"
|
||||
|
||||
@@ -52,6 +52,7 @@ const assetIcons: Record<string, string> = {
|
||||
presto: "presto",
|
||||
prestosql: "presto",
|
||||
hive: "hive",
|
||||
hbase: "hbase",
|
||||
spark: "spark-logo.png",
|
||||
apache_kylin: "apache_kylin",
|
||||
sundb: "sundb",
|
||||
|
||||
@@ -445,7 +445,7 @@ function tabColorStyle(tab: QueryTab) {
|
||||
|
||||
function tabIconClass(tab: QueryTab) {
|
||||
if (tab.mode === "mq") return "";
|
||||
if (tab.mode === "data" || tab.mode === "mongo" || tab.mode === "vector" || tab.mode === "redis" || tab.mode === "objects" || tab.mode === "structure") return "text-emerald-600 dark:text-emerald-400";
|
||||
if (tab.mode === "data" || tab.mode === "mongo" || tab.mode === "vector" || tab.mode === "redis" || tab.mode === "hbase" || tab.mode === "objects" || tab.mode === "structure") return "text-emerald-600 dark:text-emerald-400";
|
||||
return "text-blue-600 dark:text-blue-400";
|
||||
}
|
||||
|
||||
@@ -472,7 +472,7 @@ const tabBarClass = computed(() => [isClassicLayout.value ? "bg-muted" : "border
|
||||
const regularTabRowClass = computed(() => [isClassicLayout.value ? "h-9 items-stretch" : "h-10 items-center px-2", isClassicLayout.value && !hasFixedTabs.value ? "border-b" : ""]);
|
||||
|
||||
function tabMenuIcon(tab: QueryTab) {
|
||||
if (tab.mode === "data" || tab.mode === "mongo" || tab.mode === "redis") return Table2;
|
||||
if (tab.mode === "data" || tab.mode === "mongo" || tab.mode === "redis" || tab.mode === "hbase") return Table2;
|
||||
if (tab.mode === "vector") return TableProperties;
|
||||
if (tab.mode === "etcd" || tab.mode === "zookeeper") return KeyRound;
|
||||
if (tab.mode === "etcd-dashboard") return Gauge;
|
||||
@@ -615,7 +615,7 @@ function onOverflowItemKeydown(event: KeyboardEvent, tabId: string, kind: "regul
|
||||
@mouseleave="tabDrag.clearTarget(tab.id)"
|
||||
>
|
||||
<span class="shrink-0" :class="tabIconClass(tab)">
|
||||
<Table2 v-if="tab.mode === 'data' || tab.mode === 'mongo' || tab.mode === 'redis'" class="h-3.5 w-3.5" />
|
||||
<Table2 v-if="tab.mode === 'data' || tab.mode === 'mongo' || tab.mode === 'redis' || tab.mode === 'hbase'" class="h-3.5 w-3.5" />
|
||||
<DatabaseIcon v-else-if="tab.mode === 'mq'" :db-type="tabDatabaseIconType(tab)" class="h-3.5 w-3.5" />
|
||||
<TableProperties v-else-if="tab.mode === 'vector'" class="h-3.5 w-3.5" />
|
||||
<KeyRound v-else-if="tab.mode === 'etcd' || tab.mode === 'zookeeper'" class="h-3.5 w-3.5" />
|
||||
@@ -809,7 +809,7 @@ function onOverflowItemKeydown(event: KeyboardEvent, tabId: string, kind: "regul
|
||||
@mouseleave="tabDrag.clearTarget(tab.id)"
|
||||
>
|
||||
<span class="shrink-0" :class="tabIconClass(tab)">
|
||||
<Table2 v-if="tab.mode === 'data' || tab.mode === 'mongo' || tab.mode === 'redis'" class="h-3.5 w-3.5" />
|
||||
<Table2 v-if="tab.mode === 'data' || tab.mode === 'mongo' || tab.mode === 'redis' || tab.mode === 'hbase'" class="h-3.5 w-3.5" />
|
||||
<DatabaseIcon v-else-if="tab.mode === 'mq'" :db-type="tabDatabaseIconType(tab)" class="h-3.5 w-3.5" />
|
||||
<TableProperties v-else-if="tab.mode === 'vector'" class="h-3.5 w-3.5" />
|
||||
<KeyRound v-else-if="tab.mode === 'etcd' || tab.mode === 'zookeeper'" class="h-3.5 w-3.5" />
|
||||
|
||||
@@ -18,3 +18,11 @@ describe("AppTabBar close confirmation layout", () => {
|
||||
expect(tabBarSource).toContain('@click="handleCancelClose"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("AppTabBar HBase presentation", () => {
|
||||
it("uses the table icon in regular, pinned, and overflow tab surfaces", () => {
|
||||
expect(tabBarSource).toContain('if (tab.mode === "data" || tab.mode === "mongo" || tab.mode === "redis" || tab.mode === "hbase") return Table2;');
|
||||
expect(tabBarSource.match(/tab\.mode === 'hbase'/g)).toHaveLength(2);
|
||||
expect(tabBarSource).toContain('tab.mode === "hbase" || tab.mode === "objects"');
|
||||
});
|
||||
});
|
||||
@@ -90,6 +90,7 @@ import {
|
||||
import { copyNameForTreeNode, isDocumentBrowserTreeNode, objectSourceKindForTreeNode, shouldRunTreeNodeRowAction, treeNodeRowAction, treeNodeRowDoubleClickAction } from "@/lib/sidebar/treeNodeClick";
|
||||
import { dataTabOpenModeFromTreeClick, type DataTabOpenMode } from "@/lib/sidebar/dataTabOpenPolicy";
|
||||
import { isCopySidebarSelectionShortcut, isEditSidebarConnectionShortcut, isPasteSidebarSelectionShortcut } from "@/lib/editor/keyboardShortcuts";
|
||||
import { handleSidebarTreeDeleteShortcut } from "@/lib/sidebar/sidebarTreeDeleteShortcut";
|
||||
import { dataTableDoubleClickAction } from "@/lib/tabs/dataTabActivation";
|
||||
import { attachedDatabaseNameFromPath, buildCreateDatabaseSql, buildDuckDbAttachDatabaseSql, buildSqliteAttachDatabaseSql, supportsCreateDatabaseCharset, uniqueAttachedDatabaseName } from "@/lib/database/createDatabaseSql";
|
||||
import { appendCreateDatabaseErrorHint } from "@/lib/database/createDatabaseErrorHints";
|
||||
@@ -779,10 +780,20 @@ function onKeydown(event: KeyboardEvent) {
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
if (!event.metaKey && !event.ctrlKey && !event.altKey && !event.shiftKey && isDeleteTreeNodeShortcut(event)) {
|
||||
if (!requestDeleteSelectedNode()) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (
|
||||
handleSidebarTreeDeleteShortcut(event, {
|
||||
activeNode: activeNode.value,
|
||||
selectedNodes: selectedTreeNodesInVisibleOrder(),
|
||||
databaseTypeForNode,
|
||||
requestHBaseTableDelete: () => {
|
||||
ensureDangerDialogRouting();
|
||||
routeTreeItemDialogController();
|
||||
requestDeleteHBaseTable();
|
||||
return true;
|
||||
},
|
||||
requestDefaultDelete: requestDeleteSelectedNode,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (!isCopyTreeSelectionShortcut(event)) return;
|
||||
@@ -791,10 +802,6 @@ function onKeydown(event: KeyboardEvent) {
|
||||
copySelectedNames();
|
||||
}
|
||||
|
||||
function isDeleteTreeNodeShortcut(event: KeyboardEvent): boolean {
|
||||
return event.key === "Delete" || event.key === "Backspace";
|
||||
}
|
||||
|
||||
function isPasteTreeClipboardShortcut(event: KeyboardEvent): boolean {
|
||||
return isPasteSidebarSelectionShortcut(event, settingsStore.editorSettings.shortcuts);
|
||||
}
|
||||
@@ -2936,6 +2943,26 @@ function createView() {
|
||||
});
|
||||
}
|
||||
|
||||
function requestDeleteHBaseTable() {
|
||||
showHBaseDeleteTableConfirm.value = true;
|
||||
}
|
||||
|
||||
async function confirmDeleteHBaseTable() {
|
||||
const node = sidebarDangerTarget.value ?? activeNode.value;
|
||||
if (!node.connectionId || !node.database || connectionStore.getConfig(node.connectionId)?.db_type !== "hbase") return;
|
||||
try {
|
||||
await connectionStore.ensureConnected(node.connectionId);
|
||||
await api.hbaseDeleteTable(node.connectionId, node.database, node.label);
|
||||
closeDroppedTableObjectTabsForNode(node);
|
||||
connectionStore.removePinnedTreeNodes([node]);
|
||||
await connectionStore.refreshObjectListTreeNode(node.connectionId, node.database);
|
||||
toast(t("hbase.tableDeleted", { table: node.database === "default" ? node.label : `${node.database}:${node.label}` }));
|
||||
} catch (error: any) {
|
||||
toast(t("contextMenu.tableOperationFailed", { message: error?.message || String(error) }), 5000);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function createMysqlObjectTemplate() {
|
||||
const node = activeNode.value;
|
||||
if (!node.connectionId || !node.database) return;
|
||||
@@ -3002,6 +3029,7 @@ const isSelected = computed(() => connectionStore.selectedTreeNodeId === activeN
|
||||
const isMultiSelected = computed(() => connectionStore.selectedTreeNodeIdsSet.has(activeNode.value.id));
|
||||
|
||||
const dangerDialogRoutes: Array<{ flag: { value: boolean }; createRequest: () => SidebarDangerDialogRequest }> = [];
|
||||
const showHBaseDeleteTableConfirm = shallowRef(false);
|
||||
|
||||
let stopDangerDialogRouting: (() => void) | null = null;
|
||||
|
||||
@@ -3068,6 +3096,17 @@ routeDangerDialog(showDropTableConfirm, () =>
|
||||
}),
|
||||
);
|
||||
|
||||
routeDangerDialog(showHBaseDeleteTableConfirm, () => {
|
||||
const table = activeNode.value.database && activeNode.value.database !== "default" ? `${activeNode.value.database}:${activeNode.value.label}` : activeNode.value.label;
|
||||
return dangerRequest({
|
||||
title: t("hbase.deleteTable"),
|
||||
message: t("hbase.deleteTableConfirm", { table }),
|
||||
details: table,
|
||||
confirmLabel: t("common.delete"),
|
||||
confirm: confirmDeleteHBaseTable,
|
||||
});
|
||||
});
|
||||
|
||||
routeDangerDialog(showEmptyTableConfirm, () =>
|
||||
dangerRequest({
|
||||
title: t("contextMenu.confirmEmptyTableTitle"),
|
||||
@@ -3848,6 +3887,16 @@ function buildSpecialSidebarMenu(context: SidebarMenuFactoryContext): boolean {
|
||||
icon: RefreshCw,
|
||||
shortcut: shortcutRefresh,
|
||||
});
|
||||
if (!connectionStore.getConfig(node.connectionId || "")?.read_only) {
|
||||
items.push({ label: "", separator: true });
|
||||
items.push({
|
||||
label: t("hbase.deleteTable"),
|
||||
action: requestDeleteHBaseTable,
|
||||
icon: Trash2,
|
||||
shortcut: shortcutDelete,
|
||||
variant: "destructive" as const,
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { DatabaseType, TreeNode } from "@/types/database";
|
||||
import { handleSidebarTreeDeleteShortcut } from "@/lib/sidebar/sidebarTreeDeleteShortcut";
|
||||
|
||||
function tableNode(id: string, connectionId: string): TreeNode {
|
||||
return {
|
||||
id,
|
||||
label: id,
|
||||
type: "table",
|
||||
connectionId,
|
||||
database: "default",
|
||||
};
|
||||
}
|
||||
|
||||
function databaseTypeForNode(node: TreeNode): DatabaseType | undefined {
|
||||
return node.connectionId === "hbase-connection" ? "hbase" : "postgresql";
|
||||
}
|
||||
|
||||
function deleteEvent(key: "Delete" | "Backspace", init: KeyboardEventInit = {}): KeyboardEvent {
|
||||
return new KeyboardEvent("keydown", { key, cancelable: true, ...init });
|
||||
}
|
||||
|
||||
describe("sidebar tree delete shortcut", () => {
|
||||
it.each(["Delete", "Backspace"] as const)("routes %s for a single HBase table through REST deletion", (key) => {
|
||||
const activeNode = tableNode("table-1", "hbase-connection");
|
||||
const event = deleteEvent(key);
|
||||
const stopPropagation = vi.spyOn(event, "stopPropagation");
|
||||
const requestHBaseTableDelete = vi.fn(() => true);
|
||||
const requestDefaultDelete = vi.fn(() => true);
|
||||
|
||||
expect(
|
||||
handleSidebarTreeDeleteShortcut(event, {
|
||||
activeNode,
|
||||
selectedNodes: [activeNode],
|
||||
databaseTypeForNode,
|
||||
requestHBaseTableDelete,
|
||||
requestDefaultDelete,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(requestHBaseTableDelete).toHaveBeenCalledOnce();
|
||||
expect(requestDefaultDelete).not.toHaveBeenCalled();
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
expect(stopPropagation).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("blocks unsupported HBase multi-selection before generic SQL deletion", () => {
|
||||
const activeNode = tableNode("table-1", "hbase-connection");
|
||||
const selectedNodes = [activeNode, tableNode("table-2", "hbase-connection")];
|
||||
const event = deleteEvent("Delete");
|
||||
const requestHBaseTableDelete = vi.fn(() => true);
|
||||
const requestDefaultDelete = vi.fn(() => true);
|
||||
|
||||
expect(
|
||||
handleSidebarTreeDeleteShortcut(event, {
|
||||
activeNode,
|
||||
selectedNodes,
|
||||
databaseTypeForNode,
|
||||
requestHBaseTableDelete,
|
||||
requestDefaultDelete,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(requestHBaseTableDelete).not.toHaveBeenCalled();
|
||||
expect(requestDefaultDelete).not.toHaveBeenCalled();
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the existing generic delete route for SQL tables", () => {
|
||||
const activeNode = tableNode("table-1", "postgres-connection");
|
||||
const event = deleteEvent("Delete");
|
||||
const requestHBaseTableDelete = vi.fn(() => true);
|
||||
const requestDefaultDelete = vi.fn(() => true);
|
||||
|
||||
expect(
|
||||
handleSidebarTreeDeleteShortcut(event, {
|
||||
activeNode,
|
||||
selectedNodes: [activeNode],
|
||||
databaseTypeForNode,
|
||||
requestHBaseTableDelete,
|
||||
requestDefaultDelete,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(requestHBaseTableDelete).not.toHaveBeenCalled();
|
||||
expect(requestDefaultDelete).toHaveBeenCalledOnce();
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores modified delete shortcuts", () => {
|
||||
const activeNode = tableNode("table-1", "hbase-connection");
|
||||
const event = deleteEvent("Delete", { ctrlKey: true });
|
||||
const requestHBaseTableDelete = vi.fn(() => true);
|
||||
const requestDefaultDelete = vi.fn(() => true);
|
||||
|
||||
expect(
|
||||
handleSidebarTreeDeleteShortcut(event, {
|
||||
activeNode,
|
||||
selectedNodes: [activeNode],
|
||||
databaseTypeForNode,
|
||||
requestHBaseTableDelete,
|
||||
requestDefaultDelete,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(requestHBaseTableDelete).not.toHaveBeenCalled();
|
||||
expect(requestDefaultDelete).not.toHaveBeenCalled();
|
||||
expect(event.defaultPrevented).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
|
||||
|
||||
export const HBASE_ROW_LIMIT_STORAGE_KEY = "dbx-hbase-row-limit";
|
||||
export const DEFAULT_HBASE_ROW_LIMIT = "100";
|
||||
export const HBASE_ROW_LIMIT_OPTIONS = ["50", "100", "200", "500"] as const;
|
||||
|
||||
export function normalizeHBaseRowLimit(value: unknown): string {
|
||||
const candidate = typeof value === "string" || typeof value === "number" ? String(value) : "";
|
||||
return (HBASE_ROW_LIMIT_OPTIONS as readonly string[]).includes(candidate) ? candidate : DEFAULT_HBASE_ROW_LIMIT;
|
||||
}
|
||||
|
||||
export function loadHBaseRowLimit(): string {
|
||||
return normalizeHBaseRowLimit(safeLocalStorageGet(HBASE_ROW_LIMIT_STORAGE_KEY));
|
||||
}
|
||||
|
||||
export function saveHBaseRowLimit(value: unknown): string {
|
||||
const normalized = normalizeHBaseRowLimit(value);
|
||||
safeLocalStorageSet(HBASE_ROW_LIMIT_STORAGE_KEY, normalized);
|
||||
return normalized;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { DatabaseType, TreeNode } from "@/types/database";
|
||||
|
||||
interface SidebarTreeDeleteShortcutContext {
|
||||
activeNode: TreeNode;
|
||||
selectedNodes: readonly TreeNode[];
|
||||
databaseTypeForNode: (node: TreeNode) => DatabaseType | undefined;
|
||||
requestHBaseTableDelete: () => boolean;
|
||||
requestDefaultDelete: () => boolean;
|
||||
}
|
||||
|
||||
function isUnmodifiedDeleteShortcut(event: KeyboardEvent): boolean {
|
||||
return !event.metaKey && !event.ctrlKey && !event.altKey && !event.shiftKey && (event.key === "Delete" || event.key === "Backspace");
|
||||
}
|
||||
|
||||
export function handleSidebarTreeDeleteShortcut(event: KeyboardEvent, context: SidebarTreeDeleteShortcutContext): boolean {
|
||||
if (!isUnmodifiedDeleteShortcut(event)) return false;
|
||||
|
||||
const activeHBaseTable = context.activeNode.type === "table" && context.databaseTypeForNode(context.activeNode) === "hbase";
|
||||
const hasActiveMultiSelection = context.selectedNodes.length > 1 && context.selectedNodes.some((node) => node.id === context.activeNode.id);
|
||||
if (activeHBaseTable && hasActiveMultiSelection) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return true;
|
||||
}
|
||||
|
||||
const handled = activeHBaseTable ? context.requestHBaseTableDelete() : context.requestDefaultDelete();
|
||||
if (!handled) return false;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return true;
|
||||
}
|
||||
@@ -156,6 +156,26 @@ describe("queryStore database open state", () => {
|
||||
expect(store.tabs.some((tab) => tab.id === structureId)).toBe(true);
|
||||
});
|
||||
|
||||
it("closes only the matching HBase table tab after deletion", async () => {
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const store = useQueryStore();
|
||||
|
||||
const deletedTableId = store.createTab("hbase-1", "metrics", "events", "hbase", undefined, "events");
|
||||
const otherTableId = store.createTab("hbase-1", "metrics", "archive", "hbase", undefined, "archive");
|
||||
const otherNamespaceId = store.createTab("hbase-1", "default", "events", "hbase", undefined, "events");
|
||||
|
||||
store.closeDroppedTableObjectTabs({
|
||||
connectionId: "hbase-1",
|
||||
database: "metrics",
|
||||
name: "events",
|
||||
objectType: "TABLE",
|
||||
});
|
||||
|
||||
expect(store.tabs.some((tab) => tab.id === deletedTableId)).toBe(false);
|
||||
expect(store.tabs.some((tab) => tab.id === otherTableId)).toBe(true);
|
||||
expect(store.tabs.some((tab) => tab.id === otherNamespaceId)).toBe(true);
|
||||
});
|
||||
|
||||
it("matches dropped table schema candidates", async () => {
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const store = useQueryStore();
|
||||
|
||||
@@ -2012,6 +2012,10 @@ export const useQueryStore = defineStore("query", () => {
|
||||
if (tab.connectionId !== target.connectionId || tab.database !== target.database) return false;
|
||||
const targetSchemas = droppedTableObjectSchemaCandidates(target);
|
||||
|
||||
if ((target.objectType ?? "TABLE") === "TABLE" && tab.mode === "hbase") {
|
||||
return tab.sql === target.name;
|
||||
}
|
||||
|
||||
if (tab.mode === "data") {
|
||||
const tableMeta = tableMetaForDataTab(tab);
|
||||
if (!tableMeta || tableMeta.tableName !== target.name) return false;
|
||||
|
||||
Reference in new issue
Block a user