diff --git a/migrations/1769099632447_boundary_config_add_id.ts b/migrations/1769099632447_boundary_config_add_id.ts new file mode 100644 index 00000000..706581e5 --- /dev/null +++ b/migrations/1769099632447_boundary_config_add_id.ts @@ -0,0 +1,47 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { sql } from "kysely"; +import type { Kysely } from "kysely"; + +export async function up(db: Kysely): Promise { + await sql` + UPDATE map_view + SET inspector_config = jsonb_set( + inspector_config, + '{boundaries}', + ( + SELECT COALESCE( + jsonb_agg( + jsonb_set( + boundary, + '{id}', + to_jsonb(gen_random_uuid()::text) + ) + ), + '[]'::jsonb + ) + FROM jsonb_array_elements(inspector_config->'boundaries') AS boundary + ) + ) + WHERE inspector_config ? 'boundaries' + AND jsonb_typeof(inspector_config->'boundaries') = 'array' + `.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql` + UPDATE map_view + SET inspector_config = jsonb_set( + inspector_config, + '{boundaries}', + ( + SELECT COALESCE( + jsonb_agg(boundary - 'id'), + '[]'::jsonb + ) + FROM jsonb_array_elements(inspector_config->'boundaries') AS boundary + ) + ) + WHERE inspector_config ? 'boundaries' + AND jsonb_typeof(inspector_config->'boundaries') = 'array' + `.execute(db); +} diff --git a/src/app/map/[id]/components/DataSourceSelectButton.tsx b/src/app/map/[id]/components/DataSourceSelectButton.tsx new file mode 100644 index 00000000..e1cb6eb5 --- /dev/null +++ b/src/app/map/[id]/components/DataSourceSelectButton.tsx @@ -0,0 +1,237 @@ +import { PlusIcon, RotateCwIcon, X } from "lucide-react"; +import { useMemo, useState } from "react"; +import { DataSourceItem } from "@/components/DataSourceItem"; +import { Button } from "@/shadcn/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/shadcn/ui/dialog"; +import { Input } from "@/shadcn/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shadcn/ui/select"; +import { cn } from "@/shadcn/utils"; +import { useDataSources } from "../hooks/useDataSources"; +import { useMapViews } from "../hooks/useMapViews"; +import type { DataSourceWithImportInfo } from "@/components/DataSourceItem"; +import type { AreaSetCode } from "@/server/models/AreaSet"; + +export default function DataSourceSelectButton({ + areaSetCode, + className, + dataSource, + onClickRemove, + onSelect, + selectButtonText, +}: { + areaSetCode?: AreaSetCode | null | undefined; + className?: string | null | undefined; + dataSource?: DataSourceWithImportInfo | null | undefined; + onClickRemove?: () => void; + onSelect: (dataSourceId: string) => void; + selectButtonText?: string | null | undefined; +}) { + const [isModalOpen, setIsModalOpen] = useState(false); + + return ( + <> + + + + ); +} + +function DataSourceSelectButtonModalTrigger({ + className, + dataSource, + setIsModalOpen, + onClickRemove, + selectButtonText, +}: { + className?: string | null | undefined; + dataSource?: DataSourceWithImportInfo | null | undefined; + setIsModalOpen: (o: boolean) => void; + onClickRemove?: () => void; + selectButtonText?: string | null | undefined; +}) { + if (!dataSource) { + return ( + + ); + } + return ( +
+ +
+ + {onClickRemove && ( + + )} +
+
+ ); +} + +function DataSourceSelectModal({ + areaSetCode, + isModalOpen, + setIsModalOpen, + onSelect, +}: { + areaSetCode?: AreaSetCode | null | undefined; + isModalOpen: boolean; + setIsModalOpen: (o: boolean) => void; + onSelect: (dataSourceId: string) => void; +}) { + const [activeTab, setActiveTab] = useState<"all" | "public" | "user">("all"); + const [searchQuery, setSearchQuery] = useState(""); + const { data: dataSources } = useDataSources(); + const { viewConfig } = useMapViews(); + + // Update the filtering logic to include search + const filteredAndSearchedDataSources = useMemo(() => { + let sources = dataSources || []; + + if (searchQuery) { + sources = sources.filter( + (ds) => + ds.name.toLowerCase().includes(searchQuery.toLowerCase()) || + ds.columnDefs.some((col) => + col.name.toLowerCase().includes(searchQuery.toLowerCase()), + ), + ); + } + + if (activeTab === "public") { + // Include only public data sources + sources = sources.filter((ds) => ds.public); + } else if (activeTab === "user") { + // Include only user data sources + sources = sources.filter((ds) => !ds.public); + } + + if (areaSetCode) { + sources = sources.filter((ds) => { + if (!("areaSetCode" in ds.geocodingConfig)) { + return false; + } + return ds.geocodingConfig.areaSetCode === areaSetCode; + }); + } + + return sources; + }, [activeTab, areaSetCode, dataSources, searchQuery]); + return ( + + + + Select data source for visualisation + + +
+ {/* Search and Filter Bar */} +
+ setSearchQuery(e.target.value)} + /> + +
+ + {/* Data Source Grid */} +
+
+ {filteredAndSearchedDataSources.map((ds) => ( + + ))} +
+
+
+
+
+ ); +} diff --git a/src/app/map/[id]/components/controls/VisualisationPanel/VisualisationPanel.tsx b/src/app/map/[id]/components/controls/VisualisationPanel/VisualisationPanel.tsx index 8bf9e670..0aac925c 100644 --- a/src/app/map/[id]/components/controls/VisualisationPanel/VisualisationPanel.tsx +++ b/src/app/map/[id]/components/controls/VisualisationPanel/VisualisationPanel.tsx @@ -1,13 +1,5 @@ -import { - CircleAlert, - Database, - Palette, - PieChart, - PlusIcon, - RotateCwIcon, - X, -} from "lucide-react"; -import { useMemo, useState } from "react"; +import { CircleAlert, Database, Palette, PieChart, X } from "lucide-react"; +import { useState } from "react"; import { useChoropleth } from "@/app/map/[id]/hooks/useChoropleth"; import { useChoroplethDataSource, @@ -50,8 +42,8 @@ import { dataRecordsWillAggregate, getValidAreaSetGroupCodes, } from "../../Choropleth/areas"; +import DataSourceSelectButton from "../../DataSourceSelectButton"; import CategoryColorEditor from "./CategoryColorEditor"; -import { DataSourceItem } from "./DataSourceItem"; import SteppedColorEditor from "./SteppedColorEditor"; import type { AreaSetGroupCode } from "@/server/models/AreaSet"; import type { DataSource } from "@/server/models/DataSource"; @@ -147,38 +139,10 @@ export default function VisualisationPanel({ const { data: dataSources, getDataSourceById } = useDataSources(); const dataSource = useChoroplethDataSource(); - const [activeTab, setActiveTab] = useState<"all" | "public" | "user">("all"); - const [searchQuery, setSearchQuery] = useState(""); - const [isModalOpen, setIsModalOpen] = useState(false); const [invalidDataSourceId, setInvalidDataSourceId] = useState( null, ); - // Update the filtering logic to include search - const filteredAndSearchedDataSources = useMemo(() => { - let sources = dataSources || []; - - if (searchQuery) { - sources = sources.filter( - (ds) => - ds.name.toLowerCase().includes(searchQuery.toLowerCase()) || - ds.columnDefs.some((col) => - col.name.toLowerCase().includes(searchQuery.toLowerCase()), - ), - ); - } - - if (activeTab === "public") { - // Include only public data sources - sources = sources.filter((ds) => ds.public); - } else if (activeTab === "user") { - // Include only user data sources - sources = sources.filter((ds) => !ds.public); - } - - return sources; - }, [activeTab, dataSources, searchQuery]); - if (!boundariesPanelOpen) return null; const columnOneIsNumber = @@ -213,61 +177,38 @@ export default function VisualisationPanel({ - - {viewConfig.areaDataSourceId && dataSource ? ( - // Show selected data source as a card -
- -
- - -
-
- ) : ( - // Show button to open modal when no data source selected - - - )} + + updateViewConfig({ + areaDataSourceId: "", + areaDataColumn: "", + calculationType: undefined, + }) + } + onSelect={(dataSourceId) => { + const selectedAreaSetGroup = viewConfig.areaSetGroupCode; + if (!selectedAreaSetGroup) { + updateViewConfig({ + areaDataSourceId: dataSourceId, + areaDataSecondaryColumn: undefined, + }); + return; + } + const dataSource = getDataSourceById(dataSourceId); + const validAreaSetGroups = getValidAreaSetGroupCodes( + dataSource?.geocodingConfig, + ); + if (validAreaSetGroups.includes(selectedAreaSetGroup)) { + updateViewConfig({ + areaDataSourceId: dataSourceId, + areaDataSecondaryColumn: undefined, + }); + return; + } + setInvalidDataSourceId(dataSourceId); + }} + />
@@ -889,91 +830,6 @@ export default function VisualisationPanel({ )}
- {/* Modal for data source selection */} - - - - Select data source for visualisation - - -
- {/* Search and Filter Bar */} -
- setSearchQuery(e.target.value)} - /> - -
- - {/* Data Source Grid */} -
-
- {filteredAndSearchedDataSources.map((ds) => ( - - ))} -
-
-
-
-
- {/* Modal for handling invalid data source / boundary combination */} void; onUpdate: (config: InspectorBoundaryConfig) => void; }) { const { getDataSourceById } = useDataSources(); @@ -48,8 +55,15 @@ export function BoundaryConfigItem({ if (!dataSource) { return ( -
+

Data source not found

+
); } @@ -77,6 +91,20 @@ export function BoundaryConfigItem({ }); }; + const handleDataSourceIdChange = (dataSourceId: string) => { + const newDataSource = getDataSourceById(dataSourceId); + const newName = newDataSource?.name ?? configName; + + setConfigName(newName || ""); + setSelectedColumns([]); + onUpdate({ + ...boundaryConfig, + dataSourceId, + columns: [], + name: newName, + }); + }; + return (
{/* Data source info */} - handleDataSourceIdChange(dataSourceId)} /> {/* Name field */} diff --git a/src/app/map/[id]/components/inspector/BoundaryDataPanel.tsx b/src/app/map/[id]/components/inspector/BoundaryDataPanel.tsx index 55e4a0ff..ca6047b6 100644 --- a/src/app/map/[id]/components/inspector/BoundaryDataPanel.tsx +++ b/src/app/map/[id]/components/inspector/BoundaryDataPanel.tsx @@ -38,7 +38,7 @@ export function BoundaryDataPanel({ (selectedBoundary?.areaSetCode as AreaSetCode) || AreaSetCode.WMC24, }, { - enabled: Boolean(selectedBoundary?.areaSetCode), + enabled: Boolean(selectedBoundary?.areaSetCode && dataSourceId), }, ), ); diff --git a/src/app/map/[id]/components/inspector/InspectorConfigTab.tsx b/src/app/map/[id]/components/inspector/InspectorConfigTab.tsx index 657ad874..45926233 100644 --- a/src/app/map/[id]/components/inspector/InspectorConfigTab.tsx +++ b/src/app/map/[id]/components/inspector/InspectorConfigTab.tsx @@ -1,44 +1,63 @@ -import { useEffect, useRef } from "react"; +import { useCallback, useEffect, useRef } from "react"; +import { v4 as uuidv4 } from "uuid"; import { type InspectorBoundaryConfig, InspectorBoundaryConfigType, } from "@/server/models/MapView"; import { useDataSources } from "../../hooks/useDataSources"; +import { useInspector } from "../../hooks/useInspector"; import { useMapViews } from "../../hooks/useMapViews"; +import DataSourceSelectButton from "../DataSourceSelectButton"; import TogglePanel from "../TogglePanel"; import { BoundaryConfigItem } from "./BoundaryConfigItem"; export default function InspectorConfigTab() { const { view, viewConfig, updateView } = useMapViews(); const { getDataSourceById } = useDataSources(); + const { selectedBoundary } = useInspector(); const boundaryStatsConfig = view?.inspectorConfig?.boundaries || []; const initializationAttemptedRef = useRef(false); + const areaSetCode = selectedBoundary?.areaSetCode; - // Initialize boundaries with areaDataSourceId if empty - useEffect(() => { - if (!view || initializationAttemptedRef.current) return; - - const hasBoundaries = boundaryStatsConfig.length > 0; - const hasAreaDataSource = viewConfig.areaDataSourceId; + const addDataSourceToConfig = useCallback( + (dataSourceId: string) => { + if (!view) { + return; + } - if (!hasBoundaries && hasAreaDataSource) { - initializationAttemptedRef.current = true; - const dataSource = getDataSourceById(viewConfig.areaDataSourceId); + const dataSource = getDataSourceById(dataSourceId); const newBoundaryConfig: InspectorBoundaryConfig = { - dataSourceId: viewConfig.areaDataSourceId, + id: uuidv4(), + dataSourceId, name: dataSource?.name || "Boundary Data", type: InspectorBoundaryConfigType.Simple, columns: [], }; + const prevBoundaries = view.inspectorConfig?.boundaries || []; + updateView({ ...view, inspectorConfig: { ...view.inspectorConfig, - boundaries: [newBoundaryConfig], + boundaries: [...prevBoundaries, newBoundaryConfig], }, }); + }, + [getDataSourceById, updateView, view], + ); + + // Initialize boundaries with areaDataSourceId if empty + useEffect(() => { + if (!view || initializationAttemptedRef.current) return; + + const hasBoundaries = boundaryStatsConfig.length > 0; + const hasAreaDataSource = viewConfig.areaDataSourceId; + + if (!hasBoundaries && hasAreaDataSource) { + initializationAttemptedRef.current = true; + addDataSourceToConfig(viewConfig.areaDataSourceId); } }, [ view, @@ -46,17 +65,33 @@ export default function InspectorConfigTab() { boundaryStatsConfig.length, getDataSourceById, updateView, + addDataSourceToConfig, ]); return (
-
+
{boundaryStatsConfig.map((boundaryConfig, index) => ( { + if (!view) return; + const updatedBoundaries = + view.inspectorConfig?.boundaries?.filter( + (_, i) => i !== index, + ); + updateView({ + ...view, + inspectorConfig: { + ...view.inspectorConfig, + boundaries: updatedBoundaries, + }, + }); + }} onUpdate={(updatedConfig) => { if (!view) return; const updatedBoundaries = [...boundaryStatsConfig]; @@ -71,6 +106,12 @@ export default function InspectorConfigTab() { }} /> ))} + addDataSourceToConfig(dataSourceId)} + selectButtonText={"Add a data source"} + />
diff --git a/src/app/map/[id]/components/inspector/InspectorDataTab.tsx b/src/app/map/[id]/components/inspector/InspectorDataTab.tsx index cbb3b8d1..aa464c6c 100644 --- a/src/app/map/[id]/components/inspector/InspectorDataTab.tsx +++ b/src/app/map/[id]/components/inspector/InspectorDataTab.tsx @@ -54,7 +54,9 @@ export default function InspectorDataTab({ [view?.inspectorConfig?.boundaries], ); const shouldUseInspectorConfig = - boundaryConfigs.length > 0 && type === LayerType.Boundary; + boundaryConfigs.length > 0 && + type === LayerType.Boundary && + boundaryConfigs.some((c) => c.columns.length); const boundaryData = useMemo(() => { if ( @@ -98,7 +100,7 @@ export default function InspectorDataTab({ boundaryData.length > 0 ? ( boundaryData.map((item, index) => (
@@ -114,7 +113,7 @@ export default function InspectorPanel() { defaultValue="data" value={safeActiveTab} onValueChange={setActiveTab} - className="flex flex-col overflow-hidden h-full" + className="flex flex-col min-h-0" > {type !== LayerType.Cluster && ( @@ -140,10 +139,7 @@ export default function InspectorPanel() { {type !== LayerType.Cluster && ( - + )} - + {type && } - + {type === LayerType.Boundary && ( - + )} diff --git a/src/components/DataSourceItem.tsx b/src/components/DataSourceItem.tsx index 710d663e..4a84f4ff 100644 --- a/src/components/DataSourceItem.tsx +++ b/src/components/DataSourceItem.tsx @@ -5,13 +5,13 @@ import { DataSourceType } from "@/server/models/DataSource"; import { cn } from "@/shadcn/utils"; import type { RouterOutputs } from "@/services/trpc/react"; -type DataSourceItemType = NonNullable< +export type DataSourceWithImportInfo = NonNullable< RouterOutputs["dataSource"]["byOrganisation"] >[0]; // Helper function to get data source type from config export const getDataSourceType = ( - dataSource: DataSourceItemType, + dataSource: DataSourceWithImportInfo, ): DataSourceType | "unknown" => { try { const config = dataSource.config; @@ -64,7 +64,7 @@ const getDataSourceStyle = (type: DataSourceType | "unknown") => { }; */ // Helper function to get geocoding status -const getGeocodingStatus = (dataSource: DataSourceItemType) => { +const getGeocodingStatus = (dataSource: DataSourceWithImportInfo) => { const geocodingConfig = dataSource.geocodingConfig; if (geocodingConfig.type === "None") { return { status: "No geocoding", color: "text-neutral-500" }; @@ -82,7 +82,7 @@ export function DataSourceItem({ dataSource, className, }: { - dataSource: DataSourceItemType; + dataSource: DataSourceWithImportInfo; className?: string; }) { const dataSourceType = getDataSourceType(dataSource); diff --git a/src/server/models/MapView.ts b/src/server/models/MapView.ts index 81127c99..4a7de3c6 100644 --- a/src/server/models/MapView.ts +++ b/src/server/models/MapView.ts @@ -168,6 +168,7 @@ export const inspectorBoundaryTypes = Object.values( * - columns: Array of column names to display from this data source */ export const inspectorBoundaryConfigSchema = z.object({ + id: z.string(), dataSourceId: z.string(), name: z.string(), type: z.nativeEnum(InspectorBoundaryConfigType),