diff --git a/src/lib/components/buttons/DownloadDataButton.svelte b/src/lib/components/buttons/DownloadDataButton.svelte index 02f8907..b76e8ed 100644 --- a/src/lib/components/buttons/DownloadDataButton.svelte +++ b/src/lib/components/buttons/DownloadDataButton.svelte @@ -5,14 +5,19 @@ let { - downloadData + downloadData, + disabled = false, + title = '' }: { downloadData: () => void|Promise; + disabled?: boolean; + title?: string; } = $props(); let isLoading = $state(false); async function handleDownloadData(){ + if (isLoading || disabled) return; isLoading = true; try { await downloadData(); @@ -23,7 +28,7 @@ - {:else} - {/if} diff --git a/src/lib/components/query-builder/QueryActions.ts b/src/lib/components/query-builder/QueryActions.ts index 90a7109..15fea75 100644 --- a/src/lib/components/query-builder/QueryActions.ts +++ b/src/lib/components/query-builder/QueryActions.ts @@ -5,6 +5,7 @@ import { addToast } from '@/stores/toasts'; import { saveQueryFrom } from '@/stores/saved-queries'; import { goto } from '$app/navigation'; import { resolve } from '$app/paths'; +import { runBlockReason } from '@/query/query-guard'; export type ActionCallback = (() => void | Promise) | undefined; @@ -31,6 +32,14 @@ export type QueryActions = { * See `buildShareLink`. */ getInstanceRef?: () => InstanceRef | null; + /** + * The reason that the active query must not run, or null. The action bar + * disables the run and download buttons with it, and names the reason. + * + * The value is a snapshot. A component that must react to a change of the + * safeguard switch reads `$settings` beside this call. + */ + runBlockReason?: () => string | null; }; /** @@ -55,6 +64,23 @@ export function getDefaultQueryActions(workspace: QueryWorkspace): QueryActions return workspace.activeBlock?.instance ?? null; } + /** The reason that the active query must not run. See {@link runBlockReason}. */ + function activeRunBlockReason(): string | null { + return runBlockReason(QueryWorkspace.getQuery(workspace.activeBlock)); + } + + /** + * True when the safeguard stops this query. The caller must not talk to the + * node. A query with no filter reads a whole table. + */ + function isBlocked(query: CompiledQuery): boolean { + const reason = runBlockReason(query); + if (!reason) return false; + + addToast({ message: reason, type: 'warning' }); + return true; + } + /** * The node of the active block, or null with a toast. Every action that talks * to a node starts here. A missing node is a normal state: the block can come @@ -88,6 +114,8 @@ export function getDefaultQueryActions(workspace: QueryWorkspace): QueryActions return null; } + if (isBlocked(query)) return null; + const instance = requireInstance(); if (!instance) return null; @@ -133,6 +161,8 @@ export function getDefaultQueryActions(workspace: QueryWorkspace): QueryActions return; } + if (isBlocked(query)) return; + const instance = requireInstance(); if (!instance) return; @@ -207,7 +237,8 @@ export function getDefaultQueryActions(workspace: QueryWorkspace): QueryActions resetQuery, saveQuery, getInstance, - getInstanceRef + getInstanceRef, + runBlockReason: activeRunBlockReason }; } diff --git a/src/lib/components/query-builder/QueryBuilderParameterBlock.svelte b/src/lib/components/query-builder/QueryBuilderParameterBlock.svelte index 828a7d7..7d79550 100644 --- a/src/lib/components/query-builder/QueryBuilderParameterBlock.svelte +++ b/src/lib/components/query-builder/QueryBuilderParameterBlock.svelte @@ -20,9 +20,12 @@ import { addToast } from '@/stores/toasts'; import type { QuerySelectionStatus } from '@/query/selection-status'; import type { QueryActions } from './QueryActions'; - import { defaultOutputFormat, type QueryDraft } from '@/query/draft'; + import { compileDraft, defaultOutputFormat, type QueryDraft } from '@/query/draft'; import MapPinnedIcon from '@lucide/svelte/icons/map-pinned'; + import TriangleAlertIcon from '@lucide/svelte/icons/triangle-alert'; import XIcon from '@lucide/svelte/icons/x'; + import { runBlockReason } from '@/query/query-guard'; + import { settings } from '@/stores/settings'; import { describeSelection, type SpatialSelection } from '@/geo/spatial-selection'; import { hydrateDraftFromQuery } from '@/query/seed-hydration'; @@ -92,6 +95,27 @@ let lastEmittedDraftKey = $state(''); let lastReceivedDraftKey = $state(initialDraft ? JSON.stringify(initialDraft) : ''); + /** + * The reason that this query must not run, or null. It compiles the draft, + * because a filter with no value compiles to nothing. + * + * The switch is read here as well. `runBlockReason` reads a snapshot of the + * settings, so without this read the warning stays after the user turns the + * safeguard off on the settings page. + */ + const filterWarning = $derived.by(() => { + if (!$settings.requireQueryFilters) return null; + + return runBlockReason( + compileDraft({ + tableName: table_name, + selectedFields, + outputFormat: selected_output_format, + spatialFilter + }) + ); + }); + const firstVisibleItem = $derived(fields.find((item) => !(item.ref as {hidden: boolean})?.hidden)); const firstVisibleMatchingItem = $derived.by(() => { @@ -406,6 +430,13 @@ + {#if filterWarning} +

+ + {filterWarning} +

+ {/if} + {#if spatialFilter}
@@ -435,6 +466,22 @@