feat(hbase): improve desktop connections and table operations
This commit is contained in:
17 files changed
+514
-50
No files matched your search
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user