From 0e1022f04ce126b839d537b3c25100e3107a9c2c Mon Sep 17 00:00:00 2001 From: amwps290 Date: Fri, 14 Aug 2026 17:47:54 +0800 Subject: [PATCH] feat(grid): add geometry layer preview --- apps/desktop/src/components/grid/DataGrid.vue | 18 +- .../src/components/grid/DataGridToolbar.vue | 13 +- .../components/grid/LayerPreviewDialog.vue | 180 +++++++++++++++--- apps/desktop/src/i18n/locales/en.ts | 13 ++ apps/desktop/src/i18n/locales/es.ts | 13 ++ apps/desktop/src/i18n/locales/it.ts | 13 ++ apps/desktop/src/i18n/locales/ja.ts | 13 ++ apps/desktop/src/i18n/locales/ko.ts | 13 ++ apps/desktop/src/i18n/locales/pt-BR.ts | 13 ++ apps/desktop/src/i18n/locales/zh-CN.ts | 13 ++ apps/desktop/src/i18n/locales/zh-TW.ts | 13 ++ .../__tests__/dataGrid/customBasemap.spec.ts | 86 +++++++++ .../dataGridToolbarLayerPreview.spec.ts | 18 ++ .../dataGrid/geometryMapPreview.spec.ts | 17 +- .../desktop/src/lib/dataGrid/customBasemap.ts | 103 ++++++++++ .../src/lib/dataGrid/geometryMapPreview.ts | 13 +- .../src/lib/dataGrid/resultPreviewRegistry.ts | 4 +- 17 files changed, 519 insertions(+), 37 deletions(-) create mode 100644 apps/desktop/src/lib/__tests__/dataGrid/customBasemap.spec.ts create mode 100644 apps/desktop/src/lib/__tests__/dataGrid/dataGridToolbarLayerPreview.spec.ts create mode 100644 apps/desktop/src/lib/dataGrid/customBasemap.ts diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index cf94ab7c1e..39680f799c 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -122,7 +122,7 @@ import { } from "@/lib/dataGrid/dataGridTranspose"; import { canApplyGridSelectionValue, canDeleteGridRowItem, canEditGridCellDetail, matchesRowStatusFilter, shouldShowQuickEntryDraftRow, type RowStatus, type RowStatusFilter } from "@/lib/dataGrid/gridRowStatus"; import { displayCellValue, firstLineCellDisplayValue, limitDataGridCellDisplay, SQLSERVER_DATA_GRID_CELL_DISPLAY_MAX_LENGTH, type CellValue } from "@/lib/dataGrid/cellValue"; -import { getApplicablePreviewActions } from "@/lib/dataGrid/resultPreviewRegistry"; +import { getApplicablePreviewActions, type PreviewAction } from "@/lib/dataGrid/resultPreviewRegistry"; import "@/lib/dataGrid/geometryMapPreview"; import { BINARY_CELL_DOWNLOAD_MODES, @@ -3781,6 +3781,17 @@ const previewToolbarCapability = computed(() => loading: isPreviewLoading.value, onTrigger: openSqlPreview, })); +const layerPreviewToolbarCapability = computed(() => { + const action = previewActions.value.find((candidate) => candidate.id === "geometry-map-preview"); + return { + label: t("grid.layerPreview"), + visible: !!action, + disabled: props.loading || !action, + onTrigger: () => { + if (action) executePreviewAction(action); + }, + }; +}); const saveToolbarCapability = computed(() => ({ label: t(saveActionMode.value.labelKey, { count: pendingChangeCount.value }), tooltip: t(saveActionMode.value.tooltipKey, { @@ -4596,7 +4607,7 @@ function exportSelectedRowsTxt() { return exportTxt(rowIds); } -function executePreviewAction(action: { execute: (ctx: any) => any }) { +function executePreviewAction(action: PreviewAction) { const config = action.execute({ result: props.result, selectedRowIds: affectedRowIds(), @@ -4605,6 +4616,8 @@ function executePreviewAction(action: { execute: (ctx: any) => any }) { if (config) { previewDialogConfig.value = config; previewDialogOpen.value = true; + } else if (action.id === "geometry-map-preview") { + toast(t("grid.layerPreviewNoGeometryData"), 3000); } } @@ -10060,6 +10073,7 @@ const gridContextMenuItems = computed(() => { :auto-refresh="autoRefreshToolbarCapability" :add-row="addRowToolbarCapability" :delete-row="deleteRowToolbarCapability" + :layer-preview="layerPreviewToolbarCapability" :preview="previewToolbarCapability" :save="saveToolbarCapability" :rollback="rollbackToolbarCapability" diff --git a/apps/desktop/src/components/grid/DataGridToolbar.vue b/apps/desktop/src/components/grid/DataGridToolbar.vue index 5669c43742..831a1c0633 100644 --- a/apps/desktop/src/components/grid/DataGridToolbar.vue +++ b/apps/desktop/src/components/grid/DataGridToolbar.vue @@ -1,6 +1,6 @@ &`; + const escaped = escapeCustomBasemapAttribution(malicious); + + expect(escaped).toBe("<img src=x onerror="alert(1)"><svg onload='alert(2)'><script>alert(3)</script>&"); + expect(escaped).not.toMatch(/<(?:img|svg|script)\b/i); + expect(layerPreviewSource).toMatch(/basemap\.custom \? escapeCustomBasemapAttribution\(basemap\.attribution\) : basemap\.attribution/); + }); + + it("persists the actual basemap selection separately from the custom configuration", () => { + const target = storage(); + expect(saveSelectedBasemapId(target, "custom")).toBe(true); + expect(loadSelectedBasemapId(target)).toBe("custom"); + + expect(saveSelectedBasemapId(target, "osm")).toBe(true); + expect(loadSelectedBasemapId(target)).toBe("osm"); + expect(layerPreviewSource).toMatch(/saveSelectedBasemapId\(basemapStorage, basemap\.id\)/); + }); + + it("does not enable a stored custom basemap unless it was selected", () => { + const builtInIds = ["osm", "satellite"]; + expect(resolveSelectedBasemapId(null, builtInIds, true)).toBe("osm"); + expect(resolveSelectedBasemapId("osm", builtInIds, true)).toBe("osm"); + expect(resolveSelectedBasemapId("custom", builtInIds, true)).toBe("custom"); + expect(resolveSelectedBasemapId("custom", builtInIds, false)).toBe("osm"); + }); + + it("restores legacy session data with a fallback name", () => { + const target = storage(); + target.setItem("dbx-layer-preview-custom-basemap", JSON.stringify({ url: "https://tiles.example/{z}/{x}/{y}.png", overlayUrl: "", attribution: "Tiles", maxZoom: 18 })); + expect(loadCustomBasemapConfig(target)?.name).toBe("Custom basemap"); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/dataGrid/dataGridToolbarLayerPreview.spec.ts b/apps/desktop/src/lib/__tests__/dataGrid/dataGridToolbarLayerPreview.spec.ts new file mode 100644 index 0000000000..a2633b0498 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/dataGrid/dataGridToolbarLayerPreview.spec.ts @@ -0,0 +1,18 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const toolbarSource = readFileSync(new URL("../../../components/grid/DataGridToolbar.vue", import.meta.url), "utf8"); +const dataGridSource = readFileSync(new URL("../../../components/grid/DataGrid.vue", import.meta.url), "utf8"); + +describe("DataGrid geometry layer preview toolbar entry", () => { + it("opens the registered geometry preview action from a visible map button", () => { + expect(toolbarSource).toMatch(/layerPreview\?: DataGridToolbarActionCapability/); + expect(toolbarSource).toMatch(/isDataGridToolbarCapabilityVisible\(layerPreview\)/); + expect(toolbarSource).toMatch(/triggerDataGridToolbarAction\(layerPreview\)/); + expect(toolbarSource).toMatch(/ { + it("checks preview availability from column metadata without parsing result rows", () => { + const result = context().result; + expect(hasGeometryMapPreviewColumns(result)).toBe(true); + + result.rows = [ + [null, "empty"], + ["0x0101000000", "binary"], + ["not geometry", "invalid"], + ]; + expect(hasGeometryMapPreviewColumns(result)).toBe(true); + + result.column_types = ["text", "text"]; + expect(hasGeometryMapPreviewColumns(result)).toBe(false); + }); + it("exposes the detected layer SRID and binds row properties by sourceIndex", () => { const collection = buildGeometryMapFeatureCollection(context()); diff --git a/apps/desktop/src/lib/dataGrid/customBasemap.ts b/apps/desktop/src/lib/dataGrid/customBasemap.ts new file mode 100644 index 0000000000..eab010d50c --- /dev/null +++ b/apps/desktop/src/lib/dataGrid/customBasemap.ts @@ -0,0 +1,103 @@ +export interface CustomBasemapConfig { + name: string; + url: string; + overlayUrl: string; + attribution: string; + maxZoom: number; +} + +export const CUSTOM_BASEMAP_SESSION_KEY = "dbx-layer-preview-custom-basemap"; +export const BASEMAP_SELECTION_SESSION_KEY = "dbx-layer-preview-selected-basemap"; +export const DEFAULT_CUSTOM_BASEMAP_NAME = "Custom basemap"; + +const BASEMAP_ID_PATTERN = /^[a-z0-9-]{1,50}$/; + +export function escapeCustomBasemapAttribution(value: string): string { + return value.replace(/[&<>"']/g, (character) => { + switch (character) { + case "&": + return "&"; + case "<": + return "<"; + case ">": + return ">"; + case '"': + return """; + default: + return "'"; + } + }); +} + +export function isValidTileUrlTemplate(value: string): boolean { + const trimmed = value.trim(); + if (!/^https?:\/\/\S+$/i.test(trimmed)) return false; + return ["{z}", "{x}", "{y}"].every((token) => trimmed.includes(token)); +} + +export function normalizeCustomBasemapConfig(value: unknown): CustomBasemapConfig | null { + if (!value || typeof value !== "object") return null; + const candidate = value as Partial; + const name = typeof candidate.name === "string" ? candidate.name.trim().slice(0, 50) : ""; + const url = typeof candidate.url === "string" ? candidate.url.trim() : ""; + const overlayUrl = typeof candidate.overlayUrl === "string" ? candidate.overlayUrl.trim() : ""; + const attribution = typeof candidate.attribution === "string" ? candidate.attribution.trim() : ""; + const maxZoom = typeof candidate.maxZoom === "number" && Number.isFinite(candidate.maxZoom) ? Math.round(candidate.maxZoom) : 18; + if (!name || !isValidTileUrlTemplate(url) || (overlayUrl && !isValidTileUrlTemplate(overlayUrl))) return null; + return { + name, + url, + overlayUrl, + attribution, + maxZoom: Math.min(24, Math.max(1, maxZoom)), + }; +} + +export function loadCustomBasemapConfig(storage: Pick | null | undefined): CustomBasemapConfig | null { + if (!storage) return null; + try { + const raw = storage.getItem(CUSTOM_BASEMAP_SESSION_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === "object" && !("name" in parsed)) parsed.name = DEFAULT_CUSTOM_BASEMAP_NAME; + return normalizeCustomBasemapConfig(parsed); + } catch { + return null; + } +} + +export function saveCustomBasemapConfig(storage: Pick | null | undefined, config: CustomBasemapConfig): boolean { + if (!storage) return false; + try { + storage.setItem(CUSTOM_BASEMAP_SESSION_KEY, JSON.stringify(config)); + return true; + } catch { + return false; + } +} + +export function loadSelectedBasemapId(storage: Pick | null | undefined): string | null { + if (!storage) return null; + try { + const id = storage.getItem(BASEMAP_SELECTION_SESSION_KEY); + return id && BASEMAP_ID_PATTERN.test(id) ? id : null; + } catch { + return null; + } +} + +export function saveSelectedBasemapId(storage: Pick | null | undefined, id: string): boolean { + if (!storage || !BASEMAP_ID_PATTERN.test(id)) return false; + try { + storage.setItem(BASEMAP_SELECTION_SESSION_KEY, id); + return true; + } catch { + return false; + } +} + +export function resolveSelectedBasemapId(selectedId: string | null, builtInIds: readonly string[], hasCustomBasemap: boolean): string | null { + if (selectedId === "custom" && hasCustomBasemap) return selectedId; + if (selectedId && builtInIds.includes(selectedId)) return selectedId; + return builtInIds[0] ?? null; +} diff --git a/apps/desktop/src/lib/dataGrid/geometryMapPreview.ts b/apps/desktop/src/lib/dataGrid/geometryMapPreview.ts index 889186c4a4..e5b71e0f53 100644 --- a/apps/desktop/src/lib/dataGrid/geometryMapPreview.ts +++ b/apps/desktop/src/lib/dataGrid/geometryMapPreview.ts @@ -2,6 +2,7 @@ import { Map as MapIcon } from "@lucide/vue"; import { registerPreviewAction, type PreviewActionContext } from "@/lib/dataGrid/resultPreviewRegistry"; import { wktToGeoJson, type GeoJsonGeometry } from "@/lib/dataGrid/geometryPreview"; import LayerPreviewDialog from "@/components/grid/LayerPreviewDialog.vue"; +import type { QueryResult } from "@/types/database"; export interface GeometryMapFeature { type: "Feature"; @@ -21,13 +22,7 @@ registerPreviewAction({ label: "grid.layerPreview", icon: MapIcon, isAvailable(result) { - return (result.column_types ?? []).some((t) => { - const base = (t ?? "") - .trim() - .toLowerCase() - .split(/[(:\s]/)[0]; - return base === "geometry" || base === "geography"; - }); + return hasGeometryMapPreviewColumns(result); }, execute(ctx) { const featureCollection = buildGeometryMapFeatureCollection(ctx); @@ -42,6 +37,10 @@ registerPreviewAction({ }, }); +export function hasGeometryMapPreviewColumns(result: Pick): boolean { + return geometryColumnIndices(result.column_types).length > 0; +} + export function buildGeometryMapFeatureCollection(ctx: PreviewActionContext): GeometryMapFeatureCollection | null { const geomIndices = geometryColumnIndices(ctx.result.column_types); if (geomIndices.length === 0) return null; diff --git a/apps/desktop/src/lib/dataGrid/resultPreviewRegistry.ts b/apps/desktop/src/lib/dataGrid/resultPreviewRegistry.ts index 065f378c1b..983869a696 100644 --- a/apps/desktop/src/lib/dataGrid/resultPreviewRegistry.ts +++ b/apps/desktop/src/lib/dataGrid/resultPreviewRegistry.ts @@ -31,7 +31,7 @@ export interface PreviewAction { /** Optional icon component for the menu item. */ icon?: Component; /** Check whether this action should be shown for the given query result. */ - isAvailable(result: Pick): boolean; + isAvailable(result: QueryResult): boolean; /** * Execute the action. Returns a dialog configuration to render, or null * if nothing to show (e.g. no applicable data in selected rows). @@ -52,7 +52,7 @@ export function registerPreviewAction(action: PreviewAction): void { /** * Get all registered actions that are applicable for the given result. */ -export function getApplicablePreviewActions(result: Pick): PreviewAction[] { +export function getApplicablePreviewActions(result: QueryResult): PreviewAction[] { const applicable: PreviewAction[] = []; for (const action of actions.values()) { if (action.isAvailable(result)) {