feat(grid): add stop button to loading and refresh overlay
This commit is contained in:
6 files changed
+225
-29
No files matched your search
@@ -63,6 +63,7 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import QueryLoadingState from "@/components/common/QueryLoadingState.vue";
|
||||
import DataGridBusyOverlay from "@/components/grid/DataGridBusyOverlay.vue";
|
||||
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
|
||||
import LightDropdownMenu from "@/components/ui/LightDropdownMenu.vue";
|
||||
import LightTooltip from "@/components/ui/LightTooltip.vue";
|
||||
@@ -98,7 +99,6 @@ import { shouldNavigateFromTableInfoColumnClick } from "@/lib/table/tableInfoCol
|
||||
import { tableInfoTabForDrawerToggle } from "@/lib/table/tableInfoTabPreference";
|
||||
import { findTableStatistics } from "@/lib/dataGrid/tableInfoOverview";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { formatElapsedSeconds } from "@/lib/common/elapsedTime";
|
||||
import type { SqlInsertMode } from "@/lib/export/sqlInsertMode";
|
||||
import { dataGridCellDisplayText, dataGridCellEditorText } from "@/lib/dataGrid/dataGridCellCoercion";
|
||||
import { createColumnDrafts } from "@/lib/table/tableStructureEditorState";
|
||||
@@ -553,6 +553,15 @@ interface DataGridProps {
|
||||
queryEditabilityReason?: QueryEditabilityReason;
|
||||
allowInsertRows?: boolean;
|
||||
allowDeleteRows?: boolean;
|
||||
/**
|
||||
* Offers a stop action in the busy overlay. Query and data tabs pass this
|
||||
* while an execution with an id is running, so the elapsed pill can also be
|
||||
* used to end a slow load/refresh (#9979-adjacent feedback). Loaders that
|
||||
* call the backend directly cannot be cancelled and keep it off.
|
||||
*/
|
||||
showCancel?: boolean;
|
||||
cancelling?: boolean;
|
||||
cancelDisabled?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<DataGridProps>(), {
|
||||
@@ -600,6 +609,7 @@ const emit = defineEmits<{
|
||||
"update:orderByInput": [value: string];
|
||||
"local-column-filters-change": [value: Record<string, string[]>];
|
||||
changeQueryTimeout: [connectionId: string];
|
||||
cancel: [];
|
||||
}>();
|
||||
|
||||
const autoRefresh = useDataGridAutoRefresh({
|
||||
@@ -3180,11 +3190,6 @@ const canFetchNextInfiniteScrollSegment = computed(() =>
|
||||
const canJumpLastPage = computed(() => canGoNextPage.value && (hasKnownPaginationTotalRowCount.value || allRowsLoaded.value || !!props.tableMeta || !!props.countSql || !!props.countTotalRows));
|
||||
const totalRowCountBusy = computed(() => props.totalRowCountLoading === true || manualTotalRowCountLoading.value);
|
||||
const pageJumpBusy = computed(() => !!props.pageJumpProgress && props.pageJumpProgress.totalRequests > 1);
|
||||
const pageJumpProgressPercent = computed(() => {
|
||||
const progress = props.pageJumpProgress;
|
||||
if (!progress || progress.totalRequests <= 0) return 0;
|
||||
return Math.min(100, Math.round((progress.completedRequests / progress.totalRequests) * 100));
|
||||
});
|
||||
/** Automatic background counts keep rows interactive; explicit count navigation still blocks the surface. */
|
||||
const gridSurfaceBusy = computed(() => isRefreshingData.value || props.loading === true || manualTotalRowCountLoading.value || pageJumpBusy.value);
|
||||
const gridPaginationBusy = computed(() => gridSurfaceBusy.value || totalRowCountBusy.value);
|
||||
@@ -13646,27 +13651,7 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
|
||||
<div ref="gridVerticalScrollbarThumbRef" class="data-grid-vertical-scrollbar__thumb" />
|
||||
</div>
|
||||
<div v-if="gridSurfaceBusy" class="absolute inset-0 z-20 flex items-center justify-center" :class="pageJumpProgress ? 'bg-background/35 backdrop-blur-[1px]' : 'bg-background/50'">
|
||||
<div v-if="pageJumpProgress" class="w-72 max-w-[calc(100%-2rem)] rounded-lg border bg-background/95 p-3.5 shadow-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<Loader2 class="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-sm font-medium text-foreground">{{ t("grid.pageJumpLoading", { page: pageJumpProgress.targetPage }) }}</div>
|
||||
<div class="mt-0.5 flex items-center justify-between gap-3 text-[11px] text-muted-foreground">
|
||||
<span>{{ t("grid.pageJumpProgress", { current: pageJumpProgress.completedRequests, total: pageJumpProgress.totalRequests }) }}</span>
|
||||
<span class="shrink-0 tabular-nums">{{ formatElapsedSeconds(loadingElapsed) }}s</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 h-1.5 overflow-hidden rounded-full bg-muted" role="progressbar" :aria-valuemin="0" :aria-valuemax="pageJumpProgress.totalRequests" :aria-valuenow="pageJumpProgress.completedRequests">
|
||||
<div class="h-full rounded-full bg-primary transition-[width] duration-200 ease-out" :style="{ width: `${pageJumpProgressPercent}%` }" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex items-center gap-2 rounded-md border bg-background px-3 py-1.5 text-xs text-muted-foreground shadow-sm">
|
||||
<Loader2 class="w-3.5 h-3.5 animate-spin" />
|
||||
<span class="tabular-nums">{{ formatElapsedSeconds(loadingElapsed) }}s</span>
|
||||
</div>
|
||||
<DataGridBusyOverlay :elapsed-ms="loadingElapsed" :page-jump-progress="pageJumpProgress" :show-cancel="showCancel" :cancelling="cancelling" :cancel-disabled="cancelDisabled" @cancel="emit('cancel')" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { Loader2, Square } from "@lucide/vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { formatElapsedSeconds } from "@/lib/common/elapsedTime";
|
||||
import type { QueryPageJumpProgress } from "@/types/database";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** Elapsed time of the current busy surface, in milliseconds. */
|
||||
elapsedMs: number;
|
||||
/** Multi-request page jump progress; renders the larger progress card. */
|
||||
pageJumpProgress?: QueryPageJumpProgress;
|
||||
/**
|
||||
* Shows the stop action. Off by default: loaders that call the backend
|
||||
* directly and cannot be cancelled keep the plain elapsed pill.
|
||||
*/
|
||||
showCancel?: boolean;
|
||||
cancelling?: boolean;
|
||||
cancelDisabled?: boolean;
|
||||
}>(),
|
||||
{
|
||||
pageJumpProgress: undefined,
|
||||
showCancel: false,
|
||||
cancelling: false,
|
||||
cancelDisabled: false,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
cancel: [];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const pageJumpProgressPercent = computed(() => {
|
||||
const progress = props.pageJumpProgress;
|
||||
if (!progress) return 0;
|
||||
const total = Math.max(1, progress.totalRequests);
|
||||
return Math.max(0, Math.min(100, (progress.completedRequests / total) * 100));
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="pageJumpProgress" class="w-72 max-w-[calc(100%-2rem)] rounded-lg border bg-background/95 p-3.5 shadow-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<Loader2 class="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-sm font-medium text-foreground">{{ t("grid.pageJumpLoading", { page: pageJumpProgress.targetPage }) }}</div>
|
||||
<div class="mt-0.5 flex items-center justify-between gap-3 text-[11px] text-muted-foreground">
|
||||
<span>{{ t("grid.pageJumpProgress", { current: pageJumpProgress.completedRequests, total: pageJumpProgress.totalRequests }) }}</span>
|
||||
<span class="shrink-0 tabular-nums">{{ formatElapsedSeconds(elapsedMs) }}s</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 flex items-center gap-2">
|
||||
<div class="h-1.5 min-w-0 flex-1 overflow-hidden rounded-full bg-muted" role="progressbar" :aria-valuemin="0" :aria-valuemax="pageJumpProgress.totalRequests" :aria-valuenow="pageJumpProgress.completedRequests">
|
||||
<div class="h-full rounded-full bg-primary transition-[width] duration-200 ease-out" :style="{ width: `${pageJumpProgressPercent}%` }" />
|
||||
</div>
|
||||
<Button v-if="showCancel" variant="destructive" size="sm" class="data-grid-loading-cancel-button h-6 shrink-0 gap-1 px-2 text-xs" :disabled="cancelDisabled || cancelling" @click="emit('cancel')">
|
||||
<Loader2 v-if="cancelling" class="h-3 w-3 animate-spin" />
|
||||
<Square v-else class="h-3 w-3 fill-current" />
|
||||
{{ t(cancelling ? "common.stopping" : "toolbar.stopQuery") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex items-center gap-2 rounded-md border bg-background px-3 py-1.5 text-xs text-muted-foreground shadow-sm">
|
||||
<Loader2 class="w-3.5 h-3.5 animate-spin" />
|
||||
<span class="tabular-nums">{{ formatElapsedSeconds(elapsedMs) }}s</span>
|
||||
<Button v-if="showCancel" variant="destructive" size="sm" class="data-grid-loading-cancel-button h-6 shrink-0 gap-1 px-2 text-xs" :disabled="cancelDisabled || cancelling" @click="emit('cancel')">
|
||||
<Loader2 v-if="cancelling" class="h-3 w-3 animate-spin" />
|
||||
<Square v-else class="h-3 w-3 fill-current" />
|
||||
{{ t(cancelling ? "common.stopping" : "toolbar.stopQuery") }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,88 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { dispatch, findAll, findOne, hostText, mountComponent, type HostNode } from "./vueHostHarness";
|
||||
|
||||
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
|
||||
vi.mock("@lucide/vue", async () => {
|
||||
const { createPassthroughStub } = await import("./vueHostHarness");
|
||||
const icon = createPassthroughStub("Icon", "i");
|
||||
return { Loader2: icon, Square: icon };
|
||||
});
|
||||
vi.mock("@/components/ui/button", async () => ({ Button: (await import("./vueHostHarness")).createPassthroughStub("Button", "button") }));
|
||||
|
||||
import DataGridBusyOverlay from "@/components/grid/DataGridBusyOverlay.vue";
|
||||
|
||||
const findCancelButton = (root: HostNode) => findAll(root, (node) => node.props["data-stub"] === "Button");
|
||||
|
||||
describe("DataGridBusyOverlay", () => {
|
||||
it("shows the elapsed seconds without a stop action by default", () => {
|
||||
const mounted = mountComponent(DataGridBusyOverlay, { elapsedMs: 12_340 });
|
||||
|
||||
expect(hostText(mounted.root)).toContain("12.34s");
|
||||
expect(findCancelButton(mounted.root)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("formats long waits and guards non-finite elapsed values", async () => {
|
||||
const mounted = mountComponent(DataGridBusyOverlay, { elapsedMs: 312_450 });
|
||||
expect(hostText(mounted.root)).toContain("312.45s");
|
||||
|
||||
await mounted.setProps({ elapsedMs: Number.NaN });
|
||||
expect(hostText(mounted.root)).toContain("0.00s");
|
||||
});
|
||||
|
||||
it("stops the running load from the elapsed pill", () => {
|
||||
const cancel = vi.fn();
|
||||
const mounted = mountComponent(DataGridBusyOverlay, { elapsedMs: 1_000, showCancel: true, onCancel: cancel });
|
||||
const button = findCancelButton(mounted.root)[0];
|
||||
|
||||
expect(hostText(button)).toContain("toolbar.stopQuery");
|
||||
expect(button.props.disabled).toBe(false);
|
||||
|
||||
dispatch(button, "click");
|
||||
expect(cancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("switches to the cancelling state and blocks repeat clicks", () => {
|
||||
const cancel = vi.fn();
|
||||
const mounted = mountComponent(DataGridBusyOverlay, { elapsedMs: 900, showCancel: true, cancelling: true, onCancel: cancel });
|
||||
const button = findCancelButton(mounted.root)[0];
|
||||
|
||||
expect(hostText(button)).toContain("common.stopping");
|
||||
expect(button.props.disabled).toBe(true);
|
||||
|
||||
dispatch(button, "click");
|
||||
expect(cancel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("honours an explicit cancel-disabled state", () => {
|
||||
const mounted = mountComponent(DataGridBusyOverlay, { elapsedMs: 900, showCancel: true, cancelDisabled: true });
|
||||
|
||||
expect(findCancelButton(mounted.root)[0].props.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the page-jump card, its progress bar, and the stop action together", () => {
|
||||
const mounted = mountComponent(DataGridBusyOverlay, {
|
||||
elapsedMs: 45_000,
|
||||
showCancel: true,
|
||||
pageJumpProgress: { completedRequests: 3, totalRequests: 12, targetPage: 40 },
|
||||
});
|
||||
|
||||
expect(hostText(mounted.root)).toContain("grid.pageJumpLoading");
|
||||
expect(hostText(mounted.root)).toContain("grid.pageJumpProgress");
|
||||
expect(hostText(mounted.root)).toContain("45.00s");
|
||||
const progressbar = findOne(mounted.root, (node) => node.props.role === "progressbar");
|
||||
expect(progressbar.props["aria-valuenow"]).toBe(3);
|
||||
expect(progressbar.props["aria-valuemax"]).toBe(12);
|
||||
expect(findCancelButton(mounted.root)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("hides the stop action on the page-jump card for callers that cannot cancel", () => {
|
||||
const mounted = mountComponent(DataGridBusyOverlay, {
|
||||
elapsedMs: 45_000,
|
||||
pageJumpProgress: { completedRequests: 1, totalRequests: 9, targetPage: 3 },
|
||||
});
|
||||
|
||||
expect(findCancelButton(mounted.root)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -140,7 +140,7 @@ import type { ContentAreaSurfaceEmits, ContentAreaSurfaceProps } from "@/compone
|
||||
import { TABLE_FONT_SIZE_MAX, TABLE_FONT_SIZE_MIN, useSettingsStore, type DataGridRowNumberMode, type DataGridSearchMode, type ResultRunDisplayMode } from "@/stores/settingsStore";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
import { canCancelQueryExecution, isActiveResultLoading, queryExecutionLabelKey } from "@/lib/sql/queryExecutionState";
|
||||
import { canCancelQueryExecution, isActiveResultLoading, queryExecutionLabelKey, shouldShowCancelAction } from "@/lib/sql/queryExecutionState";
|
||||
import { sqlErrorDisplayPosition, sqlErrorEditorOffset, logSqlErrorPosition } from "@/lib/sql/errorPosition";
|
||||
import {
|
||||
databaseDisplayNameForTab,
|
||||
@@ -2317,6 +2317,10 @@ defineExpose({
|
||||
"
|
||||
:all-export-results="allResultExportSheets"
|
||||
:export-file-base-name="activeQueryResultExportBaseName"
|
||||
:show-cancel="shouldShowCancelAction(activeTab)"
|
||||
:cancelling="activeTab.isCancelling"
|
||||
:cancel-disabled="!canCancelQueryExecution(activeTab)"
|
||||
@cancel="emit('cancel', activeTab.id)"
|
||||
@update:order-by-input="(v: string) => (activeTab.orderByInput = v)"
|
||||
@local-column-filters-change="(filters: Record<string, string[]>) => queryStore.updateDataGridLocalColumnFilters(activeTab.id, filters)"
|
||||
@reload="(sql?: string, searchText?: string, whereInput?: string, orderBy?: string, limit?: number, offset?: number, intent?: DataGridReloadIntent) => emit('reload', activeTab.id, sql, searchText, whereInput, orderBy, limit, offset, intent)"
|
||||
@@ -2742,6 +2746,10 @@ defineExpose({
|
||||
:on-execute-sql="async (sql: string) => emit('executeSql', activeTab.id, sql)"
|
||||
:full-export-result="(onProgress?: (info: { rowsExported: number; totalRows: number | null }) => void) => queryStore.fetchTabResultForExport(activeTab.id, onProgress)"
|
||||
:export-file-base-name="activeTab.title"
|
||||
:show-cancel="shouldShowCancelAction(activeTab)"
|
||||
:cancelling="activeTab.isCancelling"
|
||||
:cancel-disabled="!canCancelQueryExecution(activeTab)"
|
||||
@cancel="emit('cancel', activeTab.id)"
|
||||
@update:where-input="(v: string) => (activeTab.whereInput = v)"
|
||||
@update:order-by-input="(v: string) => (activeTab.orderByInput = v)"
|
||||
@local-column-filters-change="(filters: Record<string, string[]>) => queryStore.updateDataGridLocalColumnFilters(activeTab.id, filters)"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isActiveResultLoading, type QueryResultLoadingStateLike } from "@/lib/sql/queryExecutionState";
|
||||
import { canCancelQueryExecution, isActiveResultLoading, shouldShowCancelAction, type QueryResultLoadingStateLike } from "@/lib/sql/queryExecutionState";
|
||||
|
||||
function state(overrides: Partial<QueryResultLoadingStateLike> = {}): QueryResultLoadingStateLike {
|
||||
return {
|
||||
@@ -33,3 +33,26 @@ describe("active query result loading", () => {
|
||||
expect(isActiveResultLoading(state({ isExecuting: true, activeResultRunId: "run-a" }))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stop action visibility", () => {
|
||||
it("hides the action when nothing is running", () => {
|
||||
expect(shouldShowCancelAction(state())).toBe(false);
|
||||
expect(shouldShowCancelAction(state({ executionId: "exec-1" }))).toBe(false);
|
||||
});
|
||||
|
||||
it("hides the action while an execution has no cancellable id yet", () => {
|
||||
expect(shouldShowCancelAction(state({ isExecuting: true }))).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the action visible while a cancel request is in flight", () => {
|
||||
const cancelling = state({ isExecuting: true, executionId: "exec-1", isCancelling: true });
|
||||
expect(shouldShowCancelAction(cancelling)).toBe(true);
|
||||
// The button must switch to its cancelling state instead of being pressable again.
|
||||
expect(canCancelQueryExecution(cancelling)).toBe(false);
|
||||
});
|
||||
|
||||
it("allows only one cancel request for an idle execution", () => {
|
||||
expect(shouldShowCancelAction(state({ isExecuting: true, executionId: "exec-1" }))).toBe(true);
|
||||
expect(canCancelQueryExecution(state({ isExecuting: true, executionId: "exec-1" }))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -26,3 +26,16 @@ export function isActiveResultLoading(state: QueryResultLoadingStateLike): boole
|
||||
export function queryExecutionLabelKey(state: Pick<QueryExecutionStateLike, "isCancelling">): "common.loading" | "common.stopping" {
|
||||
return state.isCancelling ? "common.stopping" : "common.loading";
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the stop action should be visible for a running execution.
|
||||
*
|
||||
* Unlike `canCancelQueryExecution` this stays true while a cancel request is in
|
||||
* flight, so the control keeps its place and only switches to the cancelling
|
||||
* state instead of disappearing. Executions without an id (for example the
|
||||
* metadata/preparation window of a data-tab reload) have nothing to cancel, so
|
||||
* the caller must not offer the action there.
|
||||
*/
|
||||
export function shouldShowCancelAction(state: QueryExecutionStateLike): boolean {
|
||||
return state.isExecuting && !!state.executionId;
|
||||
}
|
||||
Reference in new issue
Block a user