diff --git a/migrations/1764611637231_map_view_inspector_config.ts b/migrations/1764611637231_map_view_inspector_config.ts new file mode 100644 index 00000000..5fa6a6fc --- /dev/null +++ b/migrations/1764611637231_map_view_inspector_config.ts @@ -0,0 +1,16 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import type { Kysely } from "kysely"; + +export async function up(db: Kysely): Promise { + await db.schema + .alterTable("map_view") + .addColumn("inspector_config", "jsonb") + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema + .alterTable("map_view") + .dropColumn("inspector_config") + .execute(); +} diff --git a/src/app/map/[id]/components/MapViews.tsx b/src/app/map/[id]/components/MapViews.tsx index 1acb8d4f..2dcd8681 100644 --- a/src/app/map/[id]/components/MapViews.tsx +++ b/src/app/map/[id]/components/MapViews.tsx @@ -88,6 +88,7 @@ export default function MapViews() { name: newViewName.trim(), config: createNewViewConfig(), dataSourceViews: [], + inspectorConfig: { boundaries: [] }, mapId, createdAt: new Date(), }; diff --git a/src/app/map/[id]/components/TogglePanel.tsx b/src/app/map/[id]/components/TogglePanel.tsx new file mode 100644 index 00000000..c6ad3748 --- /dev/null +++ b/src/app/map/[id]/components/TogglePanel.tsx @@ -0,0 +1,67 @@ +import { ChevronDown } from "lucide-react"; +import { useState } from "react"; +import { cn } from "@/shadcn/utils"; +import type { LucideIcon } from "lucide-react"; + +interface TogglePanelProps { + label: string; + icon?: React.ReactNode; + defaultExpanded?: boolean; + children?: React.ReactNode; + headerRight?: React.ReactNode; + rightIconButton?: LucideIcon; + onRightIconButtonClick?: () => void; +} + +export default function TogglePanel({ + label, + icon: Icon, + defaultExpanded = false, + children, + headerRight, + rightIconButton: RightIconButton, + onRightIconButtonClick, +}: TogglePanelProps) { + const [expanded, setExpanded] = useState(defaultExpanded); + + return ( +
+
+ + + {headerRight && ( +
+ {headerRight} +
+ )} + + {RightIconButton && ( + + )} +
+ + {expanded &&
{children}
} +
+ ); +} diff --git a/src/app/map/[id]/components/inspector/BoundaryConfigItem.tsx b/src/app/map/[id]/components/inspector/BoundaryConfigItem.tsx new file mode 100644 index 00000000..fe41fd2c --- /dev/null +++ b/src/app/map/[id]/components/inspector/BoundaryConfigItem.tsx @@ -0,0 +1,162 @@ +import { Database } from "lucide-react"; +import { useMemo, useState } from "react"; +import DataSourceIcon from "@/components/DataSourceIcon"; +import { DataSourceItem, getDataSourceType } from "@/components/DataSourceItem"; +import { + type InspectorBoundaryConfig, + InspectorBoundaryConfigType, + inspectorBoundaryTypes, +} from "@/server/models/MapView"; +import { Input } from "@/shadcn/ui/input"; +import { Label } from "@/shadcn/ui/label"; +import { MultiSelect } from "@/shadcn/ui/multi-select"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shadcn/ui/select"; +import { useDataSources } from "../../hooks/useDataSources"; +import TogglePanel from "../TogglePanel"; + +export function BoundaryConfigItem({ + boundaryConfig, + index, + onUpdate, +}: { + boundaryConfig: InspectorBoundaryConfig; + index: number; + onUpdate: (config: InspectorBoundaryConfig) => void; +}) { + const { getDataSourceById } = useDataSources(); + const dataSource = getDataSourceById(boundaryConfig.dataSourceId); + const [configName, setConfigName] = useState(boundaryConfig.name || ""); + const [selectedColumns, setSelectedColumns] = useState( + boundaryConfig.columns || [], + ); + + const dataSourceType = dataSource ? getDataSourceType(dataSource) : null; + + const columnOptions = useMemo(() => { + if (!dataSource) return []; + return dataSource.columnDefs.map((col) => ({ + value: col.name, + label: col.name, + })); + }, [dataSource]); + + if (!dataSource) { + return ( +
+

Data source not found

+
+ ); + } + + const handleNameChange = (newName: string) => { + setConfigName(newName); + onUpdate({ + ...boundaryConfig, + name: newName, + }); + }; + + const handleTypeChange = (newType: InspectorBoundaryConfigType) => { + onUpdate({ + ...boundaryConfig, + type: newType, + }); + }; + + const handleColumnsChange = (newColumns: string[]) => { + setSelectedColumns(newColumns); + onUpdate({ + ...boundaryConfig, + columns: newColumns, + }); + }; + + return ( +
+ : undefined + } + defaultExpanded={true} + > +
+

+ + Data source +

+ + {/* Data source info */} + + + {/* Name field */} +
+ + handleNameChange(e.target.value)} + placeholder="e.g. Main Data" + /> +
+ + {/* Type field */} +
+ + +
+ + {/* Columns field */} +
+ + +
+
+
+
+ ); +} diff --git a/src/app/map/[id]/components/inspector/BoundaryDataPanel.tsx b/src/app/map/[id]/components/inspector/BoundaryDataPanel.tsx new file mode 100644 index 00000000..5df43101 --- /dev/null +++ b/src/app/map/[id]/components/inspector/BoundaryDataPanel.tsx @@ -0,0 +1,80 @@ +import { useQuery } from "@tanstack/react-query"; +import { useMemo } from "react"; +import TogglePanel from "@/app/map/[id]/components/TogglePanel"; +import { useInspector } from "@/app/map/[id]/hooks/useInspector"; +import DataSourceIcon from "@/components/DataSourceIcon"; +import { getDataSourceType } from "@/components/DataSourceItem"; +import { AreaSetCode } from "@/server/models/AreaSet"; +import { useTRPC } from "@/services/trpc/react"; +import { useDataSources } from "../../hooks/useDataSources"; +import PropertiesList from "./PropertiesList"; + +export function BoundaryDataPanel({ + config, + dataSourceId, + areaCode, + columns, + defaultExpanded, +}: { + config: { name: string; dataSourceId: string }; + dataSourceId: string; + areaCode: string; + columns: string[]; + defaultExpanded: boolean; +}) { + const trpc = useTRPC(); + const { selectedBoundary } = useInspector(); + const { getDataSourceById } = useDataSources(); + const dataSource = getDataSourceById(dataSourceId); + + const dataSourceType = dataSource ? getDataSourceType(dataSource) : null; + + const { data, isLoading } = useQuery( + trpc.dataRecord.byAreaCode.queryOptions( + { + dataSourceId, + areaCode, + areaSetCode: + (selectedBoundary?.areaSetCode as AreaSetCode) || AreaSetCode.WMC24, + }, + { + enabled: Boolean(selectedBoundary?.areaSetCode), + }, + ), + ); + + const filteredProperties = useMemo(() => { + if (!data?.json) return {}; + const filtered: Record = {}; + columns.forEach((columnName) => { + if (data.json[columnName] !== undefined) { + filtered[columnName] = data.json[columnName]; + } + }); + return filtered; + }, [data?.json, columns]); + + return ( + : undefined + } + defaultExpanded={defaultExpanded} + > +
+ {isLoading ? ( +
+

Loading...

+
+ ) : Object.keys(filteredProperties).length > 0 ? ( + + ) : ( +
+

No data available

+
+ )} +
+
+ ); +} diff --git a/src/app/map/[id]/components/inspector/InspectorConfigTab.tsx b/src/app/map/[id]/components/inspector/InspectorConfigTab.tsx new file mode 100644 index 00000000..0f98b045 --- /dev/null +++ b/src/app/map/[id]/components/inspector/InspectorConfigTab.tsx @@ -0,0 +1,82 @@ +import { useCallback, useEffect, useRef } from "react"; +import { + type InspectorBoundaryConfig, + InspectorBoundaryConfigType, +} from "@/server/models/MapView"; +import { useDataSources } from "../../hooks/useDataSources"; +import { useMapViews } from "../../hooks/useMapViews"; +import TogglePanel from "../TogglePanel"; +import { BoundaryConfigItem } from "./BoundaryConfigItem"; + +export default function InspectorConfigTab() { + const { view, viewConfig, updateView: updateViewOriginal } = useMapViews(); + const { getDataSourceById: getDataSourceByIdOriginal } = useDataSources(); + + const updateView = useCallback(updateViewOriginal, [updateViewOriginal]); + const getDataSourceById = useCallback(getDataSourceByIdOriginal, [ + getDataSourceByIdOriginal, + ]); + const boundaryStatsConfig = view?.inspectorConfig?.boundaries || []; + const initializationAttemptedRef = useRef(false); + + // 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; + const dataSource = getDataSourceById(viewConfig.areaDataSourceId); + const newBoundaryConfig: InspectorBoundaryConfig = { + dataSourceId: viewConfig.areaDataSourceId, + name: dataSource?.name || "Boundary Data", + type: InspectorBoundaryConfigType.Simple, + columns: [], + }; + + updateView({ + ...view, + inspectorConfig: { + ...view.inspectorConfig, + boundaries: [newBoundaryConfig], + }, + }); + } + }, [ + view, + viewConfig.areaDataSourceId, + boundaryStatsConfig.length, + getDataSourceById, + updateView, + ]); + + return ( +
+ +
+ {boundaryStatsConfig.map((boundaryConfig, index) => ( + { + if (!view) return; + const updatedBoundaries = [...boundaryStatsConfig]; + updatedBoundaries[index] = updatedConfig; + updateView({ + ...view, + inspectorConfig: { + ...view.inspectorConfig, + boundaries: updatedBoundaries, + }, + }); + }} + /> + ))} +
+
+
+ ); +} diff --git a/src/app/map/[id]/components/inspector/InspectorDataTab.tsx b/src/app/map/[id]/components/inspector/InspectorDataTab.tsx new file mode 100644 index 00000000..0303fa64 --- /dev/null +++ b/src/app/map/[id]/components/inspector/InspectorDataTab.tsx @@ -0,0 +1,180 @@ +import { useQuery } from "@tanstack/react-query"; +import { MapPinIcon, TableIcon } from "lucide-react"; +import { useMemo } from "react"; +import { useDataSources } from "@/app/map/[id]/hooks/useDataSources"; +import { useInspector } from "@/app/map/[id]/hooks/useInspector"; +import { useMapRef } from "@/app/map/[id]/hooks/useMapCore"; +import { useMapViews } from "@/app/map/[id]/hooks/useMapViews"; +import { useTable } from "@/app/map/[id]/hooks/useTable"; +import DataSourceIcon from "@/components/DataSourceIcon"; +import { type DataSource } from "@/server/models/DataSource"; +import { useTRPC } from "@/services/trpc/react"; +import { Button } from "@/shadcn/ui/button"; +import { LayerType } from "@/types"; +import { BoundaryDataPanel } from "./BoundaryDataPanel"; +import PropertiesList from "./PropertiesList"; +import type { SelectedRecord } from "@/app/map/[id]/types/inspector"; + +interface InspectorDataTabProps { + dataSource: DataSource | null | undefined; + properties: Record | null | undefined; + isDetailsView: boolean; + focusedRecord: SelectedRecord | null; + type: LayerType | undefined; +} + +export default function InspectorDataTab({ + dataSource, + properties, + isDetailsView, + focusedRecord, + type, +}: InspectorDataTabProps) { + const mapRef = useMapRef(); + const { setSelectedDataSourceId } = useTable(); + const trpc = useTRPC(); + const { view } = useMapViews(); + const { getDataSourceById } = useDataSources(); + const { selectedBoundary } = useInspector(); + + const { data: recordData, isFetching: recordLoading } = useQuery( + trpc.dataRecord.byId.queryOptions( + { + dataSourceId: focusedRecord?.dataSourceId || "", + id: focusedRecord?.id || "", + }, + { + enabled: Boolean(focusedRecord?.dataSourceId), + }, + ), + ); + + const boundaryConfigs = useMemo( + () => view?.inspectorConfig?.boundaries || [], + [view?.inspectorConfig?.boundaries], + ); + const shouldUseInspectorConfig = + boundaryConfigs.length > 0 && type === LayerType.Boundary; + + const boundaryData = useMemo(() => { + if ( + !shouldUseInspectorConfig || + !selectedBoundary?.areaCode || + !selectedBoundary?.areaSetCode + ) + return []; + + return boundaryConfigs.map((config) => { + const ds = getDataSourceById(config.dataSourceId); + + return { + config, + dataSource: ds, + dataSourceId: config.dataSourceId, + areaCode: selectedBoundary.areaCode, + areaSetCode: selectedBoundary.areaSetCode, + columns: config.columns, + }; + }); + }, [ + shouldUseInspectorConfig, + selectedBoundary?.areaCode, + selectedBoundary?.areaSetCode, + boundaryConfigs, + getDataSourceById, + ]); + + const flyToMarker = () => { + const map = mapRef?.current; + + if (map && focusedRecord?.geocodePoint) { + map.flyTo({ center: focusedRecord.geocodePoint, zoom: 12 }); + } + }; + + return ( +
+ {shouldUseInspectorConfig ? ( + // Show data based on inspector config + boundaryData.length > 0 ? ( + boundaryData.map((item, index) => ( + + )) + ) : ( +
+

No boundary data configured

+
+ ) + ) : ( + // Show default data source and properties + <> + {dataSource && ( +
+

+ Data source +

+
+
+ +
+ +

{dataSource.name}

+
+
+ )} + + {(() => { + if (recordLoading) { + return Loading...; + } + + const mergedProperties = { + ...(properties ?? {}), + ...(recordData?.json ?? {}), + }; + + const hasProperties = + mergedProperties && Object.keys(mergedProperties).length > 0; + + if (!hasProperties) { + return ( +
+

No data available

+
+ ); + } + + return ; + })()} + + )} + + {(isDetailsView || dataSource) && ( +
+ {isDetailsView && focusedRecord?.geocodePoint && ( + + )} + {dataSource && ( + + )} +
+ )} +
+ ); +} diff --git a/src/app/map/[id]/components/inspector/InspectorMarkersTab.tsx b/src/app/map/[id]/components/inspector/InspectorMarkersTab.tsx new file mode 100644 index 00000000..197a65f3 --- /dev/null +++ b/src/app/map/[id]/components/inspector/InspectorMarkersTab.tsx @@ -0,0 +1,18 @@ +import { LayerType } from "@/types"; +import BoundaryMarkersList from "./BoundaryMarkersList"; +import TurfMarkersList from "./TurfMarkersList"; + +interface InspectorMarkersTabProps { + type: LayerType; +} + +export default function InspectorMarkersTab({ + type, +}: InspectorMarkersTabProps) { + return ( +
+ {type === LayerType.Turf && } + {type === LayerType.Boundary && } +
+ ); +} diff --git a/src/app/map/[id]/components/inspector/InspectorNotesTab.tsx b/src/app/map/[id]/components/inspector/InspectorNotesTab.tsx new file mode 100644 index 00000000..74a612be --- /dev/null +++ b/src/app/map/[id]/components/inspector/InspectorNotesTab.tsx @@ -0,0 +1,7 @@ +export default function InspectorNotesTab() { + return ( +
+

No notes yet

+
+ ); +} diff --git a/src/app/map/[id]/components/inspector/InspectorPanel.tsx b/src/app/map/[id]/components/inspector/InspectorPanel.tsx index 0ef8fc18..b762b107 100644 --- a/src/app/map/[id]/components/inspector/InspectorPanel.tsx +++ b/src/app/map/[id]/components/inspector/InspectorPanel.tsx @@ -1,23 +1,13 @@ -import { useQuery } from "@tanstack/react-query"; -import { - ArrowLeftIcon, - MapPinIcon, - SettingsIcon, - TableIcon, - XIcon, -} from "lucide-react"; +import { ArrowLeftIcon, SettingsIcon, XIcon } from "lucide-react"; +import { useState } from "react"; import { useInspector } from "@/app/map/[id]/hooks/useInspector"; -import { useTable } from "@/app/map/[id]/hooks/useTable"; -import DataSourceIcon from "@/components/DataSourceIcon"; -import { useTRPC } from "@/services/trpc/react"; -import { Button } from "@/shadcn/ui/button"; import { cn } from "@/shadcn/utils"; import { LayerType } from "@/types"; -import { useMapRef } from "../../hooks/useMapCore"; -import BoundaryMarkersList from "./BoundaryMarkersList"; -import PropertiesList from "./PropertiesList"; -import TurfMarkersList from "./TurfMarkersList"; +import InspectorConfigTab from "./InspectorConfigTab"; +import InspectorDataTab from "./InspectorDataTab"; +import InspectorMarkersTab from "./InspectorMarkersTab"; +import InspectorNotesTab from "./InspectorNotesTab"; import { UnderlineTabs, UnderlineTabsContent, @@ -26,6 +16,7 @@ import { } from "./UnderlineTabs"; export default function InspectorPanel() { + const [activeTab, setActiveTab] = useState("data"); const { inspectorContent, resetInspector, @@ -35,31 +26,16 @@ export default function InspectorPanel() { setFocusedRecord, selectedRecords, } = useInspector(); - const mapRef = useMapRef(); - const { setSelectedDataSourceId, selectedDataSourceId } = useTable(); - - const trpc = useTRPC(); - const { data: recordData, isFetching: recordLoading } = useQuery( - trpc.dataRecord.byId.queryOptions( - { - dataSourceId: focusedRecord?.dataSourceId || "", - id: focusedRecord?.id || "", - }, - { - enabled: Boolean(focusedRecord?.dataSourceId), - }, - ), - ); if (!Boolean(inspectorContent)) { return <>; } const { dataSource, properties, type } = inspectorContent ?? {}; - const tableOpen = Boolean(selectedDataSourceId); - const isDetailsView = + const isDetailsView = Boolean( (selectedTurf && type !== LayerType.Turf) || - (selectedBoundary && type !== LayerType.Boundary); + (selectedBoundary && type !== LayerType.Boundary), + ); const markerCount = selectedRecords?.length || 0; @@ -67,24 +43,25 @@ export default function InspectorPanel() { setFocusedRecord(null); }; - const flyToMarker = () => { - const map = mapRef?.current; - - if (map && focusedRecord?.geocodePoint) { - map.flyTo({ center: focusedRecord.geocodePoint, zoom: 12 }); - } - }; - return (
-
+

{inspectorContent?.name as string} @@ -121,96 +98,58 @@ export default function InspectorPanel() { Data - + Markers {markerCount > 0 ? markerCount : ""} Notes 0 - + -
- {dataSource && ( -
-

- Data source -

-
-
- -
- -

{dataSource.name}

-
-
- )} - - {recordLoading ? ( - Loading... - ) : ( - - )} - - {(isDetailsView || dataSource) && ( -
- {isDetailsView && focusedRecord?.geocodePoint && ( - - )} - {dataSource && ( - - )} -
- )} -
+
-
- {type === LayerType.Turf && } - {type === LayerType.Boundary && } -
+ {type && }
-
-

No notes yet

-
+
-
-

Configuration options

-
+

diff --git a/src/app/map/[id]/hooks/useInitialMapView.ts b/src/app/map/[id]/hooks/useInitialMapView.ts index bcb7459b..3093934a 100644 --- a/src/app/map/[id]/hooks/useInitialMapView.ts +++ b/src/app/map/[id]/hooks/useInitialMapView.ts @@ -47,6 +47,7 @@ export function useInitialMapViewEffect() { name: "Default View", config: createNewViewConfig(), dataSourceViews: [], + inspectorConfig: { boundaries: [] }, mapId: mapId, position: getNewLastPosition(mapData.views), createdAt: new Date(), diff --git a/src/app/map/[id]/page.tsx b/src/app/map/[id]/page.tsx index 8fcce753..f9a8b7ca 100644 --- a/src/app/map/[id]/page.tsx +++ b/src/app/map/[id]/page.tsx @@ -2,6 +2,7 @@ import { redirect } from "next/navigation"; import { getServerSession } from "@/auth"; import { DesktopOnly } from "@/components/layout/DesktopOnly"; import SentryFeedbackWidget from "@/components/SentryFeedbackWidget"; +import { db } from "@/server/services/database"; import PrivateMap from "./components/PrivateMap"; import MapJotaiProvider from "./providers/MapJotaiProvider"; import type { Metadata } from "next"; @@ -25,6 +26,33 @@ export default async function MapPage({ const { id } = await params; const { viewId } = await searchParams; + // Log current view data (development only) + if (process.env.NODE_ENV === "development") { + try { + const mapData = await db + .selectFrom("mapView") + .where("mapId", "=", id) + .selectAll() + .execute(); + + if (mapData && mapData.length > 0) { + const currentView = viewId + ? mapData.find((v) => v.id === viewId) || mapData[0] + : mapData[0]; + + console.log("=== Current Map View Data ==="); + console.log("View ID:", currentView.id); + console.log("View Name:", currentView.name); + console.log("View Config:", currentView.config); + console.log("Inspector Config:", currentView.inspectorConfig); + console.log("Data Source Views:", currentView.dataSourceViews); + console.log("=============================="); + } + } catch (error) { + console.error("Error fetching view data:", error); + } + } + return ( diff --git a/src/components/DataSourceItem.tsx b/src/components/DataSourceItem.tsx index 0f2a74af..710d663e 100644 --- a/src/components/DataSourceItem.tsx +++ b/src/components/DataSourceItem.tsx @@ -1,12 +1,8 @@ import { formatDistanceToNow } from "date-fns"; import { Database, RefreshCw } from "lucide-react"; import DataSourceIcon from "@/components/DataSourceIcon"; -import { - type DataSourceRecordType, - DataSourceType, -} from "@/server/models/DataSource"; +import { DataSourceType } from "@/server/models/DataSource"; import { cn } from "@/shadcn/utils"; -import DataSourceRecordTypeIcon from "./DataSourceRecordTypeIcon"; import type { RouterOutputs } from "@/services/trpc/react"; type DataSourceItemType = NonNullable< @@ -14,7 +10,7 @@ type DataSourceItemType = NonNullable< >[0]; // Helper function to get data source type from config -const getDataSourceType = ( +export const getDataSourceType = ( dataSource: DataSourceItemType, ): DataSourceType | "unknown" => { try { @@ -24,7 +20,7 @@ const getDataSourceType = ( return "unknown"; } }; - +/* // Helper function to get appropriate color and label for data source type const getDataSourceStyle = (type: DataSourceType | "unknown") => { switch (type) { @@ -65,7 +61,7 @@ const getDataSourceStyle = (type: DataSourceType | "unknown") => { description: "General data source", }; } -}; +}; */ // Helper function to get geocoding status const getGeocodingStatus = (dataSource: DataSourceItemType) => { @@ -90,8 +86,6 @@ export function DataSourceItem({ className?: string; }) { const dataSourceType = getDataSourceType(dataSource); - const recordType = dataSource.recordType as DataSourceRecordType; - const style = getDataSourceStyle(dataSourceType); const geocodingStatus = getGeocodingStatus(dataSource); const lastImported = dataSource.importInfo?.lastCompleted; @@ -102,18 +96,13 @@ export function DataSourceItem({ return (
-
+
{/* Icon */} - + {/* Content */}
@@ -121,10 +110,6 @@ export function DataSourceItem({

{dataSource.name}

-
- - {style.label} -
@@ -144,7 +129,7 @@ export function DataSourceItem({
{/* Stats */} -
+
{dataSource.columnDefs.length} columns {dataSource.recordCount || "Unknown"} records diff --git a/src/server/models/MapView.ts b/src/server/models/MapView.ts index 59f7fa3f..660808a9 100644 --- a/src/server/models/MapView.ts +++ b/src/server/models/MapView.ts @@ -122,11 +122,62 @@ export const mapViewConfigSchema = z.object({ export type MapViewConfig = z.infer; +// ============================================================================ +// INSPECTOR CONFIGURATION +// ============================================================================ +// Configures which data sources and columns are displayed in the inspector panel +// for different aspects (boundaries, markers, members, etc.) + +/** + * Types of inspector boundary configurations + * - simple: Basic display of selected columns from data sources + */ +export enum InspectorBoundaryConfigType { + Simple = "simple", +} +export const inspectorBoundaryTypes = Object.values( + InspectorBoundaryConfigType, +); + +/** + * Configuration for a single boundary data source in the inspector + * - dataSourceId: Reference to the data source + * - name: User-friendly name for this inspector config + * - type: The type of inspector display (currently only "simple") + * - columns: Array of column names to display from this data source + */ +export const inspectorBoundaryConfigSchema = z.object({ + dataSourceId: z.string(), + name: z.string(), + type: z.nativeEnum(InspectorBoundaryConfigType), + columns: z.array(z.string()), +}); + +export type InspectorBoundaryConfig = z.infer< + typeof inspectorBoundaryConfigSchema +>; + +/** + * Complete inspector configuration for a map view + * Organized by aspect (boundaries, markers, members, etc.) + */ +export const inspectorConfigSchema = z.object({ + boundaries: z.array(inspectorBoundaryConfigSchema).optional(), + // Future: markers, members, etc. +}); + +export type InspectorConfig = z.infer; + +// ============================================================================ +// END INSPECTOR CONFIGURATION +// ============================================================================ + export const mapViewSchema = z.object({ id: z.string(), name: z.string(), config: mapViewConfigSchema, dataSourceViews: z.array(dataSourceViewSchema), + inspectorConfig: inspectorConfigSchema.optional(), position: z.number(), mapId: z.string(), createdAt: z.date(), diff --git a/src/server/services/database/schema.ts b/src/server/services/database/schema.ts new file mode 100644 index 00000000..b006af41 --- /dev/null +++ b/src/server/services/database/schema.ts @@ -0,0 +1,366 @@ +/** + * Database Schema Documentation + * + * This file represents the complete database schema as defined by all migrations. + * It includes all tables, columns, types, constraints, and relationships. + * + * Last updated: 2026-01-15 + * Based on migrations up to: 1764611637231_map_view_inspector_config.ts + */ + +/** + * EXTENSIONS + * - pgcrypto: For gen_random_uuid() function + * - postgis: For geography type and spatial operations + * - pg_trgm: For trigram text search operations + */ + +// ============================================================================ +// AREA MANAGEMENT +// ============================================================================ + +/** + * areaSet Table + * Represents sets of geographic areas (e.g., countries, states, regions) + */ +export interface AreaSet { + id: number; // bigserial, PRIMARY KEY + code: string; // text, UNIQUE, NOT NULL + name: string; // text, UNIQUE, NOT NULL +} + +/** + * area Table + * Individual geographic areas with spatial data + */ +export interface Area { + id: number; // bigserial, PRIMARY KEY + code: string; // text, NOT NULL + name: string; // text, NOT NULL + geography: unknown; // geography (PostGIS), NOT NULL + areaSetId: number; // bigint, NOT NULL + + // CONSTRAINTS: + // - UNIQUE (code, areaSetId) + // FOREIGN KEYS: + // - areaSetId -> areaSet.id (CASCADE DELETE, CASCADE UPDATE) + // INDEXES: + // - area_geography_gist USING GIST (geography) +} + +// ============================================================================ +// USER & ORGANIZATION MANAGEMENT +// ============================================================================ + +/** + * user Table + * Application users with authentication + */ +export interface User { + id: string; // uuid, PRIMARY KEY, DEFAULT gen_random_uuid() + email: string; // text, UNIQUE, NOT NULL + passwordHash: string; // text, NOT NULL + name: string; // text, NOT NULL, DEFAULT '' + avatarUrl: string | null; // text, NULL + createdAt: string; // text, DEFAULT CURRENT_TIMESTAMP, NOT NULL +} + +/** + * organisation Table + * Organizations that users belong to + */ +export interface Organisation { + id: string; // uuid, PRIMARY KEY, DEFAULT gen_random_uuid() + name: string; // text, UNIQUE, NOT NULL + avatarUrl: string | null; // text, NULL + createdAt: string; // text, DEFAULT CURRENT_TIMESTAMP, NOT NULL +} + +/** + * organisationUser Table + * Junction table for many-to-many relationship between organisations and users + */ +export interface OrganisationUser { + id: number; // bigserial, NOT NULL + organisationId: string; // uuid, NOT NULL + userId: string; // uuid, NOT NULL + + // CONSTRAINTS: + // - UNIQUE (organisationId, userId) + // FOREIGN KEYS: + // - organisationId -> organisation.id (CASCADE DELETE, CASCADE UPDATE) + // - userId -> user.id (CASCADE DELETE, CASCADE UPDATE) +} + +/** + * invitation Table + * User invitations to join organizations + */ +export interface Invitation { + id: string; // uuid, PRIMARY KEY, DEFAULT gen_random_uuid() + email: string; // text, NOT NULL + name: string; // text, NOT NULL + organisationId: string; // uuid, NOT NULL + userId: string | null; // uuid, NULL + used: boolean; // boolean, NOT NULL, DEFAULT false + createdAt: string; // text, DEFAULT CURRENT_TIMESTAMP, NOT NULL + updatedAt: string; // text, DEFAULT CURRENT_TIMESTAMP, NOT NULL + + // FOREIGN KEYS: + // - organisationId -> organisation.id (CASCADE DELETE, CASCADE UPDATE) + // - userId -> user.id (SET NULL DELETE, CASCADE UPDATE) +} + +// ============================================================================ +// DATA SOURCES & RECORDS +// ============================================================================ + +/** + * dataSource Table + * External data sources that can be imported and mapped + */ +export interface DataSource { + id: string; // uuid, PRIMARY KEY, DEFAULT gen_random_uuid() + name: string; // text, NOT NULL + config: object; // jsonb, UNIQUE, NOT NULL, DEFAULT {} + geocodingConfig: object; // jsonb, NOT NULL, DEFAULT {} + columnDefs: unknown[]; // jsonb, NOT NULL, DEFAULT [] + columnRoles: object; // jsonb, NOT NULL, DEFAULT {} (renamed from columnsConfig) + enrichments: unknown[]; // jsonb, NOT NULL, DEFAULT [] (renamed from enrichmentConfig) + organisationId: string; // uuid, NOT NULL + autoImport: boolean; // boolean, NOT NULL, DEFAULT false + autoEnrich: boolean; // boolean, NOT NULL, DEFAULT false + public: boolean; // boolean, NOT NULL, DEFAULT false + recordType: string; // text, NOT NULL, DEFAULT 'Other' (Members, People, Locations, Events, Data, Other) + dateFormat: string; // text, NOT NULL, DEFAULT 'yyyy-MM-dd' + createdAt: Date; // timestamp, DEFAULT CURRENT_TIMESTAMP, NOT NULL + + // FOREIGN KEYS: + // - organisationId -> organisation.id (CASCADE DELETE, CASCADE UPDATE) +} + +/** + * dataRecord Table + * Individual records from data sources + */ +export interface DataRecord { + id: number; // bigserial, NOT NULL + externalId: string; // text, NOT NULL + json: object; // jsonb, NOT NULL, DEFAULT {} + geocodeResult: object | null; // jsonb, NULL + dataSourceId: string; // uuid, NOT NULL + geocodePoint: unknown | null; // geography, NULL + jsonTextSearch: string; // text, GENERATED ALWAYS AS (jsonb_values_text(json)) STORED + needsImport: boolean; // boolean, NOT NULL, DEFAULT false + needsEnrich: boolean; // boolean, NOT NULL, DEFAULT false + createdAt: Date; // timestamp, DEFAULT CURRENT_TIMESTAMP, NOT NULL + + // CONSTRAINTS: + // - UNIQUE (externalId, dataSourceId) + // FOREIGN KEYS: + // - dataSourceId -> dataSource.id (CASCADE DELETE, CASCADE UPDATE) + // INDEXES: + // - dataRecordDataSourceIdIndex ON (dataSourceId) + // - data_record_geocode_point_gist USING GIST (geocode_point) + // - dataRecordJsonTextSearchIdx USING GIN (json_text_search gin_trgm_ops) +} + +/** + * CUSTOM FUNCTIONS: + * - jsonb_values_text(data jsonb): Converts all JSONB values to searchable text + */ + +// ============================================================================ +// WEBHOOKS & INTEGRATIONS +// ============================================================================ + +/** + * airtableWebhook Table + * Airtable webhook registrations for real-time sync + */ +export interface AirtableWebhook { + id: string; // varchar(32), PRIMARY KEY + cursor: number; // integer, NOT NULL + createdAt: string; // text, DEFAULT CURRENT_TIMESTAMP, NOT NULL +} + +// ============================================================================ +// MAPS & VIEWS +// ============================================================================ + +/** + * map Table + * Maps that contain data visualizations and annotations + */ +export interface Map { + id: string; // uuid, PRIMARY KEY, DEFAULT gen_random_uuid() + organisationId: string; // uuid, NOT NULL + name: string; // text, NOT NULL + imageUrl: string | null; // text, NULL + config: object; // jsonb, NOT NULL, DEFAULT {} + createdAt: Date; // timestamp, DEFAULT CURRENT_TIMESTAMP, NOT NULL + + // FOREIGN KEYS: + // - organisationId -> organisation.id (CASCADE DELETE, CASCADE UPDATE) + // INDEXES: + // - idx_map_config_member_data_source_id USING GIN ((config->'memberDataSourceId')) + // - idx_map_config_marker_data_source_ids USING GIN ((config->'markerDataSourceIds')) +} + +/** + * mapView Table + * Different views/configurations for a map + */ +export interface MapView { + id: string; // uuid, PRIMARY KEY, DEFAULT gen_random_uuid() + config: object; // jsonb, NOT NULL + mapId: string; // uuid, NOT NULL + name: string; // text, NOT NULL, DEFAULT 'Untitled' + position: number; // double precision, NOT NULL, DEFAULT 0 + dataSourceViews: unknown[]; // jsonb, NOT NULL, DEFAULT [] + inspectorConfig: unknown[] | null; // jsonb, NULL - Array of InspectorDataSourceConfig + createdAt: Date; // timestamp, DEFAULT CURRENT_TIMESTAMP, NOT NULL + + // FOREIGN KEYS: + // - mapId -> map.id (CASCADE DELETE, CASCADE UPDATE) +} + +/** + * publicMap Table + * Public sharing configurations for maps + */ +export interface PublicMap { + id: string; // uuid, PRIMARY KEY, DEFAULT gen_random_uuid() + host: string; // text, UNIQUE, NOT NULL + name: string; // text, NOT NULL + description: string; // text, NOT NULL, DEFAULT '' + descriptionLink: string; // text, NOT NULL, DEFAULT '' + descriptionLong: string | null; // text, NULL + mapId: string; // uuid, NOT NULL + viewId: string; // uuid, UNIQUE, NOT NULL + published: boolean; // boolean, NOT NULL, DEFAULT false + dataSourceConfigs: unknown[]; // jsonb, NOT NULL, DEFAULT [] + colorScheme: string | null; // text, NULL (renamed from colour_scheme -> color_scheme) + imageUrl: string | null; // text, NULL + createdAt: string; // text, DEFAULT CURRENT_TIMESTAMP, NOT NULL + + // FOREIGN KEYS: + // - mapId -> map.id (CASCADE DELETE, CASCADE UPDATE) + // - viewId -> mapView.id (CASCADE DELETE, CASCADE UPDATE) +} + +// ============================================================================ +// MAP ANNOTATIONS +// ============================================================================ + +/** + * folder Table + * Folders for organizing placed markers + */ +export interface Folder { + id: string; // uuid, PRIMARY KEY, DEFAULT gen_random_uuid() + mapId: string; // uuid, NOT NULL + name: string; // text, NOT NULL + notes: string; // text, NOT NULL, DEFAULT '' + position: number; // double precision, NOT NULL, DEFAULT 0 + hideMarkers: boolean | null; // boolean, NULL + + // FOREIGN KEYS: + // - mapId -> map.id (CASCADE DELETE, CASCADE UPDATE) +} + +/** + * placedMarker Table + * User-placed markers on maps + */ +export interface PlacedMarker { + id: string; // uuid, PRIMARY KEY, DEFAULT gen_random_uuid() + mapId: string; // uuid, NOT NULL + label: string; // text, NOT NULL + notes: string; // text, NOT NULL, DEFAULT '' + point: unknown; // geography, NOT NULL + folderId: string | null; // uuid, NULL + position: number; // double precision, NOT NULL, DEFAULT 0 + + // FOREIGN KEYS: + // - mapId -> map.id (CASCADE DELETE, CASCADE UPDATE) + // - folderId -> folder.id (RESTRICT DELETE, CASCADE UPDATE) + // INDEXES: + // - placed_marker_point_gist USING GIST (point) +} + +/** + * turf Table + * Polygon areas drawn on maps (turfs) + */ +export interface Turf { + id: string; // uuid, PRIMARY KEY, DEFAULT gen_random_uuid() + label: string; // text, NOT NULL + notes: string; // text, NOT NULL, DEFAULT '' + area: number; // double precision, NOT NULL + polygon: unknown; // geography, NOT NULL + createdAt: Date; // timestamp, DEFAULT CURRENT_TIMESTAMP, NOT NULL + mapId: string; // uuid, NOT NULL + + // FOREIGN KEYS: + // - mapId -> map.id (CASCADE DELETE, CASCADE UPDATE) + // INDEXES: + // - turf_polygon_gist USING GIST (polygon) +} + +// ============================================================================ +// DATABASE RELATIONSHIPS SUMMARY +// ============================================================================ + +/** + * RELATIONSHIP DIAGRAM: + * + * organisation + * ├─> organisationUser ─> user + * ├─> dataSource + * │ └─> dataRecord + * ├─> map + * │ ├─> mapView ─> publicMap + * │ ├─> folder + * │ │ └─> placedMarker + * │ └─> turf + * └─> invitation ─> user + * + * areaSet + * └─> area + * + * airtableWebhook (standalone) + */ + +// ============================================================================ +// TYPE EXPORTS +// ============================================================================ + +export interface Database { + // Area Management + areaSet: AreaSet; + area: Area; + + // User & Organization + user: User; + organisation: Organisation; + organisationUser: OrganisationUser; + invitation: Invitation; + + // Data Sources & Records + dataSource: DataSource; + dataRecord: DataRecord; + + // Webhooks & Integrations + airtableWebhook: AirtableWebhook; + + // Maps & Views + map: Map; + mapView: MapView; + publicMap: PublicMap; + + // Map Annotations + folder: Folder; + placedMarker: PlacedMarker; + turf: Turf; +} diff --git a/src/shadcn/ui/multi-select.tsx b/src/shadcn/ui/multi-select.tsx new file mode 100644 index 00000000..123b11c5 --- /dev/null +++ b/src/shadcn/ui/multi-select.tsx @@ -0,0 +1,124 @@ +"use client"; + +import { Command as CommandPrimitive } from "cmdk"; +import { X } from "lucide-react"; +import * as React from "react"; +import { Badge } from "@/shadcn/ui/badge"; +import { + Command, + CommandGroup, + CommandItem, + CommandList, +} from "@/shadcn/ui/command"; +import { cn } from "@/shadcn/utils"; + +export interface Option { + value: string; + label: string; +} + +interface MultiSelectProps { + options: Option[]; + selected: string[]; + onChange: (values: string[]) => void; + placeholder?: string; + className?: string; +} + +export function MultiSelect({ + options, + selected, + onChange, + placeholder = "Select items...", + className, +}: MultiSelectProps) { + const inputRef = React.useRef(null); + const [open, setOpen] = React.useState(false); + const [inputValue, setInputValue] = React.useState(""); + + const handleUnselect = (value: string) => { + onChange(selected.filter((s) => s !== value)); + }; + + const handleSelect = (value: string) => { + if (selected.includes(value)) { + onChange(selected.filter((s) => s !== value)); + } else { + onChange([...selected, value]); + } + }; + + const selectables = options.filter( + (option) => !selected.includes(option.value), + ); + + return ( + +
+
+ {selected.map((value) => { + const option = options.find((o) => o.value === value); + return ( + + {option?.label} + + + ); + })} + setOpen(false)} + onFocus={() => setOpen(true)} + placeholder={selected.length === 0 ? placeholder : undefined} + className="ml-2 flex-1 bg-transparent outline-none placeholder:text-muted-foreground" + /> +
+
+
+ + {open && selectables.length > 0 ? ( +
+ + {selectables.map((option) => { + return ( + { + e.preventDefault(); + e.stopPropagation(); + }} + onSelect={() => { + handleSelect(option.value); + setInputValue(""); + }} + className="cursor-pointer" + > + {option.label} + + ); + })} + +
+ ) : null} +
+
+
+ ); +}