diff --git a/react/package-lock.json b/react/package-lock.json index 7e07541a..3700bf5a 100644 --- a/react/package-lock.json +++ b/react/package-lock.json @@ -9,9 +9,11 @@ "version": "1.0.0", "license": "MIT", "dependencies": { + "@dnd-kit/sortable": "^10.0.0", "@fortawesome/fontawesome-svg-core": "^6.7.2", "@fortawesome/free-solid-svg-icons": "^6.7.2", "@fortawesome/react-fontawesome": "^0.2.2", + "@hookform/resolvers": "^3.10.0", "@reduxjs/toolkit": "^2.5.0", "@tacc/core-components": "^0.0.3-beta.0", "@tacc/core-styles": "^2.37.2", @@ -29,6 +31,8 @@ "react-datepicker": "^7.6.0", "react-dom": "^18.3.1", "react-esri-leaflet": "^2.0.1", + "react-hook-form": "^7.54.2", + "react-hook-form-antd": "^1.1.3", "react-leaflet": "^4.2.1", "react-leaflet-markercluster": "^4.2.1", "react-redux": "^9.2.0", @@ -37,7 +41,8 @@ "react-table": "^7.8.0", "reactstrap": "^9.2.3", "uuid": "^11.0.5", - "yup": "^1.6.1" + "yup": "^1.6.1", + "zod": "^3.24.1" }, "devDependencies": { "@babel/preset-env": "^7.26.0", @@ -3230,6 +3235,61 @@ "node": ">=10.0.0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "peer": true, + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@emotion/hash": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", @@ -3985,6 +4045,15 @@ "react": ">=16.3" } }, + "node_modules/@hookform/resolvers": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.10.0.tgz", + "integrity": "sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag==", + "license": "MIT", + "peerDependencies": { + "react-hook-form": "^7.0.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -23610,6 +23679,34 @@ "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz", "integrity": "sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==" }, + "node_modules/react-hook-form": { + "version": "7.54.2", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.54.2.tgz", + "integrity": "sha512-eHpAUgUjWbZocoQYUHposymRb4ZP6d0uwUnooL2uOybA9/3tPUvoAKqEWK1WaSiTxxOfTpffNZP7QwlnM3/gEg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, + "node_modules/react-hook-form-antd": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/react-hook-form-antd/-/react-hook-form-antd-1.1.3.tgz", + "integrity": "sha512-ibFRmcxs80xFCEtAwvYDi/30PHaIU47gbncQ39Sfhp4J1vajuD/gSxkDaBhn+g3QHLHJY3a+3EHzKm+vQDakxw==", + "license": "MIT", + "peerDependencies": { + "antd": "^5", + "react": "^16.8.0 || ^17 || ^18", + "react-dom": "^16.8.0 || ^17 || ^18", + "react-hook-form": "^7" + } + }, "node_modules/react-is": { "version": "19.0.0", "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.0.0.tgz", @@ -27405,6 +27502,15 @@ "toposort": "^2.0.2", "type-fest": "^2.19.0" } + }, + "node_modules/zod": { + "version": "3.24.1", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.1.tgz", + "integrity": "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/react/package.json b/react/package.json index a63f7eaf..ebd80c01 100644 --- a/react/package.json +++ b/react/package.json @@ -33,9 +33,11 @@ ] }, "dependencies": { + "@dnd-kit/sortable": "^10.0.0", "@fortawesome/fontawesome-svg-core": "^6.7.2", "@fortawesome/free-solid-svg-icons": "^6.7.2", "@fortawesome/react-fontawesome": "^0.2.2", + "@hookform/resolvers": "^3.10.0", "@reduxjs/toolkit": "^2.5.0", "@tacc/core-components": "^0.0.3-beta.0", "@tacc/core-styles": "^2.37.2", @@ -53,6 +55,8 @@ "react-datepicker": "^7.6.0", "react-dom": "^18.3.1", "react-esri-leaflet": "^2.0.1", + "react-hook-form": "^7.54.2", + "react-hook-form-antd": "^1.1.3", "react-leaflet": "^4.2.1", "react-leaflet-markercluster": "^4.2.1", "react-redux": "^9.2.0", @@ -61,7 +65,8 @@ "react-table": "^7.8.0", "reactstrap": "^9.2.3", "uuid": "^11.0.5", - "yup": "^1.6.1" + "yup": "^1.6.1", + "zod": "^3.24.1" }, "devDependencies": { "@babel/preset-env": "^7.26.0", diff --git a/react/src/components/CreateLayerModal/CreateLayerModal.tsx b/react/src/components/CreateLayerModal/CreateLayerModal.tsx new file mode 100644 index 00000000..1078a626 --- /dev/null +++ b/react/src/components/CreateLayerModal/CreateLayerModal.tsx @@ -0,0 +1,354 @@ +import React, { useEffect, useCallback } from 'react'; +import { + Modal, + Select, + Layout, + Flex, + Form, + Input, + InputNumber, + Button, + ThemeConfig, + ConfigProvider, +} from 'antd'; +import { PlusOutlined } from '@ant-design/icons'; +import { FormItem } from 'react-hook-form-antd'; +import { useForm, FormProvider } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import * as z from 'zod'; +import { usePostTileServer } from '@hazmapper/hooks'; +import { tileLayerSchema } from '@hazmapper/pages/MapProject'; +import { TileServerLayer } from '@hazmapper/types'; +import { PrimaryButton } from '@hazmapper/common_components/Button'; + +const formTheme: ThemeConfig = { + components: { + Form: { + itemMarginBottom: 14, + }, + Input: { + paddingBlock: 14, + }, + InputNumber: { + paddingBlock: 15, + }, + }, +}; + +const CreateLayerModal: React.FC<{ + isOpen: boolean; + closeModal: () => void; + projectId: number; + addTileLayer: (layer: TileServerLayer) => void; +}> = ({ isOpen, closeModal, projectId, addTileLayer }) => { + const { + mutate: createTileLayer, + data, + isSuccess, + reset: resetCreateTileLayer, + } = usePostTileServer({ projectId }); + const { Content, Header } = Layout; + const formSchema = z.object({ + importMethod: z.string(), + tileLayer: tileLayerSchema.omit({ id: true, attribution: true }), + attributionName: z.string().nullish(), + attributionLink: z.string().url().nullish(), + }); + type TFormSchema = z.infer; + + const [form] = Form.useForm(); + + const initialValues = { + importMethod: 'suggestions', + tileLayer: { + name: '', + type: 'tms', + url: '', + tileOptions: { + maxZoom: undefined, + minZoom: undefined, + format: undefined, + layers: undefined, + }, + uiOptions: { + opacity: 0.5, + isActive: true, + showDescription: false, + showInput: false, + zIndex: 0, + }, + }, + attributionName: null, + attributionLink: null, + }; + + const methods = useForm({ + defaultValues: initialValues, + resolver: zodResolver(formSchema), + mode: 'onChange', + }); + const { + control, + handleSubmit, + formState: { isDirty, isValid }, + reset, + watch, + } = methods; + + const generateAttribution = ( + attributionName?: string | null, + attributionLink?: string | null + ) => { + let copyright = ''; + + if (attributionName) { + copyright = '© '; + if (attributionLink) { + copyright += ''; + copyright += attributionName + ''; + } else { + copyright += copyright + attributionName; + } + } + + return copyright; + }; + + const handleSubmitCallback = (data: TFormSchema) => { + const submitData = { + ...data.tileLayer, + attribution: generateAttribution( + data.attributionName, + data.attributionLink + ), + }; + createTileLayer(submitData as TileServerLayer); + }; + + const handleClose = useCallback(() => { + closeModal(); + reset(); + }, [reset, closeModal]); + + useEffect(() => { + if (isSuccess && data) { + addTileLayer(data); + resetCreateTileLayer(); + handleClose(); + } + }, [resetCreateTileLayer, addTileLayer, handleClose, isSuccess, data]); + + const defaultTileServers: ReadonlyArray> = [ + { + name: 'Roads', + type: 'tms', + url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', + attribution: + '© OpenStreetMap contributors', + uiOptions: { + opacity: 1, + isActive: true, + showDescription: false, + showInput: false, + zIndex: 0, + }, + tileOptions: { + minZoom: 0, + maxZoom: 24, + maxNativeZoom: 19, + }, + }, + { + name: 'Satellite', + type: 'tms', + url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', + attribution: + 'Tiles © Esri — Source: Esri, i-cubed, USDA, USGS, AEX, \ + GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community', + uiOptions: { + zIndex: 0, + opacity: 1, + isActive: true, + showDescription: false, + showInput: false, + }, + tileOptions: { + minZoom: 0, + maxZoom: 24, + maxNativeZoom: 19, + }, + }, + ]; + + return ( + Create a Tile Layer} + open={isOpen} + onCancel={handleClose} + zIndex={2000} + footer={[ + , + , + ]} + > + + + +
+ + TMS }, + { value: 'wms', label: WMS }, + { value: 'arcgis', label: ArcGIS }, + ]} + /> + + + + + + + + + {watch('tileLayer.type') !== 'arcgis' && ( + <> + + + + + + + + + + )} + {watch('tileLayer.type') === 'tms' && ( + <> + + + + + + + + )} + {watch('tileLayer.type') === 'wms' && ( + <> + + + + + + + ) : ( + + {truncateMiddle( + watch(`tileLayers.${index}.layer.name`), + 13 + )} + + )} + + + + + + )} + +
+
+
+ + setIsModalOpen(false)} + projectId={projectId} + addTileLayer={addTileLayer} + /> + + ); +}; + +export default LayersPanel; diff --git a/react/src/components/LayersPanel/SortableItem.tsx b/react/src/components/LayersPanel/SortableItem.tsx new file mode 100644 index 00000000..51deb3be --- /dev/null +++ b/react/src/components/LayersPanel/SortableItem.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; + +export const SortableItem: React.FC<{ + id: string; + children: React.ReactNode; + disabled?: boolean; +}> = ({ id, children, disabled }) => { + const { attributes, listeners, setNodeRef, transform, transition } = + useSortable({ id, disabled }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + + return ( +
+ {children} +
+ ); +}; diff --git a/react/src/components/LayersPanel/index.ts b/react/src/components/LayersPanel/index.ts new file mode 100644 index 00000000..0bd5dcb3 --- /dev/null +++ b/react/src/components/LayersPanel/index.ts @@ -0,0 +1 @@ +export { default } from './LayersPanel'; diff --git a/react/src/components/Map/Map.test.tsx b/react/src/components/Map/Map.test.tsx index 88f8c8f8..b9be2d9a 100644 --- a/react/src/components/Map/Map.test.tsx +++ b/react/src/components/Map/Map.test.tsx @@ -3,10 +3,34 @@ import { renderInTest } from '@hazmapper/test/testUtil'; import Map from './Map'; import { tileServerLayers } from '../../__fixtures__/tileServerLayerFixture'; import { featureCollection } from '../../__fixtures__/featuresFixture'; +import { useForm, FormProvider } from 'react-hook-form'; +import * as z from 'zod'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { tileLayerSchema } from '@hazmapper/pages/MapProject'; test('renders map', () => { - const { getByText } = renderInTest( - - ); - expect(getByText(/Map/)).toBeDefined(); + const Wrapper = () => { + const formSchema = z.object({ + tileLayers: z.array( + z.object({ + layer: tileLayerSchema, // Need to nest tile layer here since useFieldArray will add it's own `id` field, overwriting our own + }) + ), + }); + + const methods = useForm({ + defaultValues: tileServerLayers, + resolver: zodResolver(formSchema), + mode: 'onChange', + }); + + return ( + + + + ); + }; + + const { getByText } = renderInTest(); + expect(getByText(/Leaflet/)).toBeDefined(); }); diff --git a/react/src/components/Map/Map.tsx b/react/src/components/Map/Map.tsx index c21c3f79..471ca139 100644 --- a/react/src/components/Map/Map.tsx +++ b/react/src/components/Map/Map.tsx @@ -9,9 +9,9 @@ import { } from 'react-leaflet'; import MarkerClusterGroup from 'react-leaflet-markercluster'; import { TiledMapLayer } from 'react-esri-leaflet'; - +import { useWatch } from 'react-hook-form'; import { - TileServerLayer, + TLayerOptionsFormData, FeatureCollection, Feature, getFeatureType, @@ -27,11 +27,6 @@ import 'leaflet/dist/leaflet.css'; import 'react-leaflet-markercluster/styles'; interface LeafletMapProps { - /** - * Tile servers used as base layers of map - */ - baseLayers?: TileServerLayer[]; - /** * Features of map */ @@ -56,10 +51,7 @@ const getFeatureStyle = (feature: any) => { * * Note this is not called Map as causes an issue with react-leaflet */ -const LeafletMap: React.FC = ({ - baseLayers = [], - featureCollection, -}) => { +const LeafletMap: React.FC = ({ featureCollection }) => { const { selectedFeatureId, setSelectedFeatureId } = useFeatureSelection(); const handleFeatureClick = useCallback( @@ -71,8 +63,16 @@ const LeafletMap: React.FC = ({ [selectedFeatureId] ); + const baseLayers = useWatch({ + name: 'tileLayers', + defaultValue: [], + }); + const activeBaseLayers = useMemo( - () => baseLayers.filter((layer) => layer.uiOptions.isActive), + () => + baseLayers + .map((item) => item.layer) + .filter((layer) => layer.uiOptions.isActive), [baseLayers] ); interface FeatureAccumulator { diff --git a/react/src/components/MapProjectNavBar/MapProjectNavBar.module.css b/react/src/components/MapProjectNavBar/MapProjectNavBar.module.css index 30ac3529..e004545e 100644 --- a/react/src/components/MapProjectNavBar/MapProjectNavBar.module.css +++ b/react/src/components/MapProjectNavBar/MapProjectNavBar.module.css @@ -2,8 +2,9 @@ height: 100%; width: var(--hazmapper-panel-navbar-width); min-width: var(--hazmapper-panel-navbar-width); - overflow-y: auto; /* Allows scrolling */ + overflow-x: hidden; /* Hide horizontal scrollbar */ padding-top: var(--global-space--section-top); + border-right: 1px solid #707070; } .root :global([class*='nav-content']) { @@ -13,7 +14,7 @@ .navItem { height: 70px; - width: 75px; + width: var(--hazmapper-panel-navbar-width); display: flex; flex-direction: column; align-items: center; diff --git a/react/src/components/Panel/Panel.module.css b/react/src/components/Panel/Panel.module.css new file mode 100644 index 00000000..72853bda --- /dev/null +++ b/react/src/components/Panel/Panel.module.css @@ -0,0 +1,9 @@ +.root { + padding: 5px; +} + +.header { + font-size: 2.5rem; + border-bottom: 1px solid #707070; + text-align: center; +} diff --git a/react/src/components/Panel/Panel.tsx b/react/src/components/Panel/Panel.tsx new file mode 100644 index 00000000..b8737283 --- /dev/null +++ b/react/src/components/Panel/Panel.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import styles from './Panel.module.css'; +import { Layout, Flex, LayoutProps } from 'antd'; + +const Panel: React.FC = ({ + panelTitle, + children, + ...props +}) => { + const { Header, Content } = Layout; + + return ( + + +
{panelTitle}
+ {children} +
+
+ ); +}; + +export default Panel; diff --git a/react/src/components/Panel/index.ts b/react/src/components/Panel/index.ts new file mode 100644 index 00000000..1eee39f0 --- /dev/null +++ b/react/src/components/Panel/index.ts @@ -0,0 +1 @@ +export { default as Panel } from './Panel'; diff --git a/react/src/hooks/index.ts b/react/src/hooks/index.ts index bba07e8b..e2d21b5d 100644 --- a/react/src/hooks/index.ts +++ b/react/src/hooks/index.ts @@ -5,7 +5,7 @@ export { useDsProjects, } from './projects/useProjects'; export * from './features/'; -export { useTileServers } from './tileServers/useTileServers'; +export * from './tileServers/useTileServers'; export { default as useSystems } from './systems/useSystems'; export { useFiles } from './files/useFiles'; export * from './environment'; diff --git a/react/src/hooks/projects/useProjects.ts b/react/src/hooks/projects/useProjects.ts index 46f8da39..fc475ddd 100644 --- a/react/src/hooks/projects/useProjects.ts +++ b/react/src/hooks/projects/useProjects.ts @@ -90,9 +90,8 @@ export const useDeleteProject = () => { endpoint: ({ projectId }) => `/projects/${projectId}/`, apiService: ApiService.Geoapi, options: { - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: 'projects' }); - }, + onSuccess: () => + queryClient.invalidateQueries({ queryKey: ['projects'] }), }, }); }; diff --git a/react/src/hooks/tileServers/useTileServers.ts b/react/src/hooks/tileServers/useTileServers.ts index 47157371..7bd0b80b 100644 --- a/react/src/hooks/tileServers/useTileServers.ts +++ b/react/src/hooks/tileServers/useTileServers.ts @@ -1,26 +1,73 @@ -import { UseQueryResult } from '@tanstack/react-query'; -import { useGet } from '../../requests'; +import { UseQueryResult, useQueryClient } from '@tanstack/react-query'; +import { useGet, useDelete, usePost, usePut } from '../../requests'; import { TileServerLayer } from '@hazmapper/types'; -interface UseTileServerParams { +interface UseGetTileServerParams { projectId?: number; isPublicView: boolean; options?: object; } -export const useTileServers = ({ +interface UsePostTileServerParams { + projectId: number; +} + +export interface UseDeleteTileServerParams { + projectId: number; + tileLayerId: number; +} + +export const useGetTileServers = ({ projectId, isPublicView, - options = {}, -}: UseTileServerParams): UseQueryResult => { + options = { + staleTime: 1000 * 60 * 5, // 5 minute stale time + refetchOnMount: false, + }, +}: UseGetTileServerParams): UseQueryResult => { const tileServersRoute = isPublicView ? 'public-projects' : 'projects'; const endpoint = `/${tileServersRoute}/${projectId}/tile-servers/`; const query = useGet({ endpoint, - key: ['tile-servers', { projectId, isPublicView }], + key: ['useGetTileServers', { projectId, isPublicView }], options, }); return query; }; + +export const usePutTileServer = ({ projectId }: UsePostTileServerParams) => { + const queryClient = useQueryClient(); + return usePut({ + endpoint: `/projects/${projectId}/tile-servers/`, + options: { + onSuccess: () => + queryClient.invalidateQueries({ + queryKey: ['useGetTileServers'], + }), + }, + }); +}; + +export const usePostTileServer = ({ projectId }: UsePostTileServerParams) => { + return usePost, TileServerLayer>({ + endpoint: `/projects/${projectId}/tile-servers/`, + }); +}; + +export const useDeleteTileServer = ({ + projectId, + tileLayerId, +}: UseDeleteTileServerParams) => { + const queryClient = useQueryClient(); + return useDelete({ + endpoint: `/projects/${projectId}/tile-servers/${tileLayerId}/`, + options: { + onSuccess: () => + queryClient.invalidateQueries({ + queryKey: ['useGetTileServers'], + }), + }, + }); +}; diff --git a/react/src/index.tsx b/react/src/index.tsx index f5e85867..247bc3a7 100644 --- a/react/src/index.tsx +++ b/react/src/index.tsx @@ -11,7 +11,7 @@ import { ConfigProvider, ThemeConfig } from 'antd'; const themeConfig: ThemeConfig = { token: { borderRadius: 0, - colorPrimary: '#337ab7', + colorPrimary: '#74B566', colorError: '#d9534f', colorPrimaryTextHover: 'black', colorBorderSecondary: '#b7b7b7', @@ -28,6 +28,11 @@ const themeConfig: ThemeConfig = { }, Layout: { bodyBg: 'transparent', + headerBg: 'transparent', + headerPadding: 0, + footerBg: 'transparent', + footerPadding: '0 16px', + siderBg: 'transparent', }, }, }; diff --git a/react/src/pages/MapProject/MapProject.module.css b/react/src/pages/MapProject/MapProject.module.css index 29ec1a4f..329af743 100644 --- a/react/src/pages/MapProject/MapProject.module.css +++ b/react/src/pages/MapProject/MapProject.module.css @@ -34,14 +34,12 @@ .panelContainer { width: var(--hazmapper-panel-width); - height: calc( - 100vh - var(--hazmapper-header-navbar-height) - - var(--hazmapper-control-bar-height) - ); + height: 100%; background-color: var(--global-color-primary--xx-light); - position: absolute; + position: fixed; left: var(--hazmapper-panel-navbar-width); - z-index: 5000; + overflow-y: auto; + z-index: 1050; } .detailContainer { min-width: 250px; @@ -53,7 +51,7 @@ right: 10px; top: 75px; bottom: 20px; - z-index: 5000; + z-index: 1050; } .map { diff --git a/react/src/pages/MapProject/MapProject.tsx b/react/src/pages/MapProject/MapProject.tsx index 2d1b4728..4e091fe9 100644 --- a/react/src/pages/MapProject/MapProject.tsx +++ b/react/src/pages/MapProject/MapProject.tsx @@ -1,18 +1,19 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useMemo } from 'react'; import { useLocation, useParams } from 'react-router-dom'; import { useQueryClient } from '@tanstack/react-query'; - +import { Layout, Flex } from 'antd'; import { Message, LoadingSpinner } from '@tacc/core-components'; import Map from '@hazmapper/components/Map'; import AssetsPanel from '@hazmapper/components/AssetsPanel'; import AssetDetail from '@hazmapper/components/AssetDetail'; +import LayersPanel from '@hazmapper/components/LayersPanel'; import ManageMapProjectModal from '@hazmapper/components/ManageMapProjectModal'; import { queryPanelKey, Panel } from '@hazmapper/utils/panels'; import { useFeatures, useProject, - useTileServers, + useGetTileServers, useFeatureSelection, KEY_USE_FEATURES, } from '@hazmapper/hooks'; @@ -22,6 +23,33 @@ import { assetTypeOptions } from '@hazmapper/components/FiltersPanel/Filter'; import { Project } from '@hazmapper/types'; import HeaderNavBar from '@hazmapper/components/HeaderNavBar'; import styles from './MapProject.module.css'; +import { Spinner } from '@hazmapper/common_components'; +import { Panel as BasePanel } from '@hazmapper/components/Panel'; +import { zodResolver } from '@hookform/resolvers/zod'; +import * as z from 'zod'; +import { useForm, FormProvider } from 'react-hook-form'; + +export const tileLayerSchema = z.object({ + id: z.number(), + name: z.string().min(1, 'Required'), + type: z.string(), + url: z.string().url().min(1, 'Required'), + attribution: z.string(), + tileOptions: z.object({ + maxZoom: z.number().nullish(), + minZoom: z.number().nullish(), + maxNativeZoom: z.number().nullish(), + format: z.string().nullish(), + layers: z.string().nullish(), + }), + uiOptions: z.object({ + zIndex: z.number(), + opacity: z.number(), + isActive: z.boolean(), + showInput: z.boolean().nullish(), + showDescription: z.boolean().nullish(), + }), +}); interface MapProjectProps { /** @@ -151,7 +179,7 @@ const LoadedMapProject: React.FC = ({ data: tileServerLayers, isLoading: isTileServerLayersLoading, error: tileServerLayersError, - } = useTileServers({ + } = useGetTileServers({ projectId: activeProject.id, isPublicView, }); @@ -175,58 +203,116 @@ const LoadedMapProject: React.FC = ({ features: [], }; + const { Header, Content, Sider } = Layout; + + const formSchema = z.object({ + tileLayers: z.array( + z.object({ + layer: tileLayerSchema, // Need to nest tile layer here since useFieldArray will add it's own `id` field, overwriting our own + }) + ), + }); + + const initialValues = useMemo( + () => ({ + tileLayers: + tileServerLayers + ?.sort((a, b) => b.uiOptions.zIndex - a.uiOptions.zIndex) + .map((layer) => ({ layer })) || [], + }), + [tileServerLayers] + ); + + const methods = useForm({ + defaultValues: initialValues, + resolver: zodResolver(formSchema), + mode: 'onChange', + }); + + const { reset } = methods; + + useEffect(() => { + reset(initialValues); + }, [initialValues, reset]); + return ( -
- -
- MapTopControlBar TODO https://tacc-main.atlassian.net/browse/WG-260 - {loading &&
loading
} -
-
- - {activePanel && activePanel !== Panel.Manage && ( -
- {activePanel === Panel.Assets && ( - - )} - {activePanel === Panel.Filters && ( - - )} -
- )} - {activePanel === Panel.Manage && ( - - )} -
- -
- {selectedFeature && ( -
- toggleSelectedFeature(selectedFeature.id)} - isPublicView={activeProject.public} - /> + + +
+ +
+ MapTopControlBar TODO https://tacc-main.atlassian.net/browse/WG-260
- )} -
-
+ + + + + + {activePanel && activePanel !== Panel.Manage && !loading && ( + + {activePanel === Panel.Assets && ( + + )} + {activePanel === Panel.Filters && ( + + )} + {activePanel === Panel.Layers && ( + + )} + + )} + + + + {loading ? ( + + ) : ( + <> + {activePanel === Panel.Manage && ( + + )} +
+ +
+ {selectedFeature && ( +
+ toggleSelectedFeature(selectedFeature.id)} + isPublicView={activeProject.public} + /> +
+ )} + + )} +
+
+ + ); }; diff --git a/react/src/pages/MapProject/index.ts b/react/src/pages/MapProject/index.ts index 3fb1de48..979caee8 100644 --- a/react/src/pages/MapProject/index.ts +++ b/react/src/pages/MapProject/index.ts @@ -1 +1 @@ -export { default } from './MapProject'; +export { default, tileLayerSchema } from './MapProject'; diff --git a/react/src/requests.ts b/react/src/requests.ts index 22005fdc..2d5c97f8 100644 --- a/react/src/requests.ts +++ b/react/src/requests.ts @@ -196,3 +196,35 @@ export function useDelete({ ...options, }); } + +export function usePut({ + endpoint, + options = {}, + apiService = ApiService.Geoapi, +}: UsePostParams) { + const client = axios; + const state = store.getState(); + const configuration = useAppConfiguration(); + + useEnsureAuthenticatedUserHasValidTapisToken(); + + const baseUrl = getBaseApiUrl(apiService, configuration); + + const headers = getHeaders(apiService, state.auth); + + const putUtil = async (requestData: RequestType) => { + const response = await client.put( + `${baseUrl}${endpoint}`, + requestData, + { + headers: headers, + } + ); + return response.data; + }; + + return useMutation({ + mutationFn: putUtil, + ...options, + }); +} diff --git a/react/src/types/index.ts b/react/src/types/index.ts index 05d59cf1..ab1c9b2d 100644 --- a/react/src/types/index.ts +++ b/react/src/types/index.ts @@ -1,4 +1,4 @@ -export type { TileServerLayer } from './tileServerLayer'; +export * from './tileServerLayer'; export * from './feature'; export type { Project, diff --git a/react/src/types/tileServerLayer.ts b/react/src/types/tileServerLayer.ts index fd47ab23..26e9a3ae 100644 --- a/react/src/types/tileServerLayer.ts +++ b/react/src/types/tileServerLayer.ts @@ -43,3 +43,9 @@ export interface TileServerLayer { showDescription?: boolean; }; } + +export type TLayerOptionsFormData = { + tileLayers: { + layer: TileServerLayer; + }[]; +}; diff --git a/react/src/utils/panels.ts b/react/src/utils/panels.ts index 5ee957ef..0c9d8690 100644 --- a/react/src/utils/panels.ts +++ b/react/src/utils/panels.ts @@ -2,7 +2,7 @@ export const queryPanelKey = 'panel'; export enum Panel { Assets = 'Assets', - PointClouds = 'PointClouds', + PointClouds = 'Point Clouds', Layers = 'Layers', Filters = 'Filters', Streetview = 'Streetview', diff --git a/react/src/utils/truncateMiddle.ts b/react/src/utils/truncateMiddle.ts new file mode 100644 index 00000000..732e34e1 --- /dev/null +++ b/react/src/utils/truncateMiddle.ts @@ -0,0 +1,29 @@ +/** + * Truncates a string with ellipses in the middle. + * + * @param {string} s - A string param + * @param {number} maxLen - Maximum length of resulting string, including ellipses + * @return {string} Truncated string + * + * @example + * // returns "this...ing" + * truncateMiddle('this is a long string', 10) + */ +export function truncateMiddle(s: string, maxLen: number) { + if (!s) { + return ''; + } + if (maxLen < 5) { + throw new Error( + 'Cannot middle truncate string with a maximum length less than 5.' + ); + } + if (s.length > maxLen) { + // starting index of ellipses + const start = Math.max(1, Math.floor(maxLen / 2) - 1); + // ending index of ellipses + const end = -Math.max(1, Math.ceil(maxLen / 2) - 2); + return `${s.substring(0, start)}...${s.slice(end)}`; + } + return s; +}