diff --git a/react/src/components/AssetsPanel/AssetsPanel.tsx b/react/src/components/AssetsPanel/AssetsPanel.tsx index ba90099f..d92f5c79 100644 --- a/react/src/components/AssetsPanel/AssetsPanel.tsx +++ b/react/src/components/AssetsPanel/AssetsPanel.tsx @@ -4,7 +4,11 @@ import FeatureFileTree from '@hazmapper/components/FeatureFileTree'; import { FeatureCollection, Project, TapisFilePath } from '@hazmapper/types'; import { Flex, Layout, Button } from 'antd'; import { PlusOutlined } from '@ant-design/icons'; -import { useFeatures, useImportFeature } from '@hazmapper/hooks'; +import { + useFeatures, + useImportFeature, + useNotification, +} from '@hazmapper/hooks'; import { IMPORTABLE_FEATURE_TYPES } from '@hazmapper/utils/fileUtils'; import FileBrowserModal from '../FileBrowserModal/FileBrowserModal'; @@ -87,17 +91,28 @@ const AssetsPanel: React.FC = ({ featureCollection, project, }) => { + const notification = useNotification(); const [isModalOpen, setIsModalOpen] = useState(false); const { mutate: importFeatureFiles } = useImportFeature(project.id); const handleFileImport = (files: TapisFilePath[]) => { - importFeatureFiles({ files }); - setIsModalOpen(false); - }; - - const toggleModal = () => { - setIsModalOpen(!isModalOpen); + importFeatureFiles( + { files }, + { + onSuccess: () => { + setIsModalOpen(false); + notification.success({ + description: 'Import started!', + }); + }, + onError: () => { + notification.error({ + description: 'Import failed! Try again?', + }); + }, + } + ); }; const { Content, Header, Footer } = Layout; @@ -106,7 +121,7 @@ const AssetsPanel: React.FC = ({ <>
-
@@ -126,7 +141,7 @@ const AssetsPanel: React.FC = ({
setIsModalOpen(false)} onImported={handleFileImport} allowedFileExtensions={IMPORTABLE_FEATURE_TYPES} /> diff --git a/react/src/components/DeleteMapModal/DeleteMapModal.tsx b/react/src/components/DeleteMapModal/DeleteMapModal.tsx index 73471a10..3e5f2ca2 100644 --- a/react/src/components/DeleteMapModal/DeleteMapModal.tsx +++ b/react/src/components/DeleteMapModal/DeleteMapModal.tsx @@ -1,9 +1,11 @@ import React from 'react'; import { Modal, ModalHeader, ModalBody, ModalFooter } from 'reactstrap'; import { Button, SectionMessage } from '@tacc/core-components'; -import { useNavigate, useLocation } from 'react-router-dom'; -import { Project } from '../../types'; -import { useDeleteProject } from '../../hooks/projects/'; +import { useNavigate } from 'react-router-dom'; +import { Project } from '@hazmapper/types'; +import { useDeleteProject } from '@hazmapper/hooks/projects/'; +import { useNotification } from '@hazmapper/hooks'; +import * as ROUTES from '@hazmapper/constants/routes'; type DeleteMapModalProps = { isOpen: boolean; @@ -17,14 +19,13 @@ const DeleteMapModal = ({ project, }: DeleteMapModalProps) => { const navigate = useNavigate(); - const location = useLocation(); + const notification = useNotification(); const { mutate: deleteProject, isPending: isDeletingProject, isError, isSuccess, } = useDeleteProject(); - const handleClose = () => { parentToggle(); }; @@ -35,19 +36,10 @@ const DeleteMapModal = ({ { onSuccess: () => { parentToggle(); - if (location.pathname.includes(`/project/${project.uuid}`)) { - // If on project page, navigate home with success state - navigate('/', { - replace: true, - state: { onSuccess: true }, - }); - } else { - // If not on project page, just navigate to current location with success state - navigate(location, { - replace: true, - state: { onSuccess: true }, - }); - } + navigate(ROUTES.MAIN); + notification.success({ + description: 'Your map was successfully deleted.', + }); }, } ); diff --git a/react/src/components/ManageMapProjectPanel/ManageMapProjectPanel.tsx b/react/src/components/ManageMapProjectPanel/ManageMapProjectPanel.tsx index f9d20686..9c0e0b6a 100644 --- a/react/src/components/ManageMapProjectPanel/ManageMapProjectPanel.tsx +++ b/react/src/components/ManageMapProjectPanel/ManageMapProjectPanel.tsx @@ -1,12 +1,12 @@ import React, { useState } from 'react'; import styles from './ManageMapProjectPanel.module.css'; import type { TabsProps } from 'antd'; -import { Flex, Tabs, notification, Card } from 'antd'; +import { Flex, Tabs, Card } from 'antd'; import { Project, ProjectRequest } from '@hazmapper/types'; import MapTabContent from './MapTabContent'; import MembersTabContent from './MembersTabContent'; import PublicTabContent from './PublicTabContent'; -import { useUpdateProjectInfo } from '@hazmapper/hooks'; +import { useUpdateProjectInfo, useNotification } from '@hazmapper/hooks'; import SaveTabContent from './SaveTabContent'; interface ManageMapProjectModalProps { @@ -17,7 +17,7 @@ const ManageMapProjectPanel: React.FC = ({ project: activeProject, }) => { const [activeKey, setActiveKey] = useState('1'); - const [updateApi, contextHolder] = notification.useNotification(); + const notification = useNotification(); const { mutate, isPending } = useUpdateProjectInfo(); @@ -30,21 +30,13 @@ const ManageMapProjectPanel: React.FC = ({ mutate(newData, { onSuccess: () => { - updateApi.open({ - type: 'success', - message: 'Success!', + notification.success({ description: 'Your project was successfully updated.', - placement: 'bottomLeft', - closable: false, }); }, onError: () => { - updateApi.open({ - type: 'error', - message: 'Error!', + notification.error({ description: 'There was an error updating your project.', - placement: 'bottomLeft', - closable: false, }); }, }); @@ -99,7 +91,6 @@ const ManageMapProjectPanel: React.FC = ({ return ( - {contextHolder} void; + error: (config: NotificationConfig) => void; + info: (config: NotificationConfig) => void; + warning: (config: NotificationConfig) => void; + open: (config: NotificationConfig) => void; +}; + +// Create context with default value and proper typing +export const NotificationContext = React.createContext({ + success: () => {}, + error: () => {}, + info: () => {}, + warning: () => {}, + open: () => {}, +}); + +export const NotificationProvider: React.FC<{ + children: React.ReactNode; +}> = ({ children }) => { + const [api, contextHolder] = notification.useNotification(); + + const notificationApi: NotificationAPI = React.useMemo(() => { + const defaultProps: Partial = { + placement: 'bottomLeft', + closable: false, + key: uuidv4(), + /* antd reuses notifications when you don’t specify a key; so previous notification might briefly reappear without key*/ + }; + + return { + success: (config) => + api.success({ message: 'Success', ...defaultProps, ...config }), + error: (config) => + api.error({ message: 'Error', ...defaultProps, ...config }), + info: (config) => + api.info({ message: 'Info', ...defaultProps, ...config }), + warning: (config) => + api.warning({ message: 'Warning', ...defaultProps, ...config }), + open: (config) => + api.open({ message: 'Unknown', ...defaultProps, ...config }), + }; + }, [api]); + + return ( + + {contextHolder} + {children} + + ); +}; diff --git a/react/src/hooks/index.ts b/react/src/hooks/index.ts index 92b3e9ca..d14dead5 100644 --- a/react/src/hooks/index.ts +++ b/react/src/hooks/index.ts @@ -7,3 +7,4 @@ export * from './projects'; export * from './user'; export * from './map'; export * from './pointClouds'; +export * from './notifications'; diff --git a/react/src/hooks/notifications/index.ts b/react/src/hooks/notifications/index.ts new file mode 100644 index 00000000..9bc6d0b8 --- /dev/null +++ b/react/src/hooks/notifications/index.ts @@ -0,0 +1,2 @@ +export { useNotification } from './useNotification'; +export { useGeoapiNotificationsPolling } from './useGeoapiNotificationsPolling'; diff --git a/react/src/hooks/notifications/useGeoapiNotificationsPolling.ts b/react/src/hooks/notifications/useGeoapiNotificationsPolling.ts new file mode 100644 index 00000000..cd518265 --- /dev/null +++ b/react/src/hooks/notifications/useGeoapiNotificationsPolling.ts @@ -0,0 +1,90 @@ +import { useEffect } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { Notification } from '@hazmapper/types'; +import { useGet } from '@hazmapper/requests'; +import { + KEY_USE_FEATURES, + KEY_USE_POINT_CLOUDS, + KEY_USE_TILE_SERVERS, +} from '@hazmapper/hooks'; +import { useNotification } from './useNotification'; + +const POLLING_INTERVAL = 5000; // 5 seconds + +export const useGeoapiNotificationsPolling = () => { + const queryClient = useQueryClient(); + const notification = useNotification(); + + const getStartDate = () => { + // Get the current timestamp minus the polling interval + const now = new Date(); + const then = new Date(now.getTime() - POLLING_INTERVAL); + return then.toISOString(); + }; + + const { data: recentNotifications } = useGet({ + endpoint: '/notifications/', + key: ['notifications'], + params: { + startDate: getStartDate(), + }, + options: { + refetchInterval: POLLING_INTERVAL, + refetchIntervalInBackground: true, + retry: 3, + }, + }); + + useEffect(() => { + if (recentNotifications?.length) { + const hasSuccessNotification = recentNotifications.some( + (note) => note.status === 'success' + ); + + recentNotifications.forEach((note) => { + notification.open({ + type: note.status, + message: `${note.status[0].toUpperCase()}${note.status.slice(1)}`, + description: note.message, + }); + }); + + if (hasSuccessNotification) { + // we assume that if some action was updated we need to refresh things so + // we invalidate relevant queries. + // Note we are doing a force refetch as makes unique KEY_USE_FEATURES work + + Promise.all( + [KEY_USE_FEATURES, KEY_USE_POINT_CLOUDS, KEY_USE_TILE_SERVERS].map( + (key) => + queryClient + .invalidateQueries({ + queryKey: [key], + refetchType: 'all', + }) + .then(() => { + const queries = queryClient.getQueriesData({ + queryKey: [key], + }); + // Force refetch active queries for each key + return Promise.all( + queries.map(([queryKey]) => + queryClient.refetchQueries({ + queryKey, + type: 'active', + exact: true, + }) + ) + ); + }) + ) + ); + } + } + }, [recentNotifications, notification, queryClient]); + + return { + recentNotifications, + isPolling: true, + }; +}; diff --git a/react/src/hooks/notifications/useNotification.ts b/react/src/hooks/notifications/useNotification.ts new file mode 100644 index 00000000..ccad4339 --- /dev/null +++ b/react/src/hooks/notifications/useNotification.ts @@ -0,0 +1,15 @@ +import React from 'react'; +import { NotificationContext } from '@hazmapper/context/NotificationProvider'; + +/** + * Custom hook to access the notification context for client-side notifications (i.e. toasts) via antd's notifications. + */ +export const useNotification = () => { + const context = React.useContext(NotificationContext); + if (!context) { + throw new Error( + 'useNotification must be used within a NotificationProvider' + ); + } + return context; +}; diff --git a/react/src/hooks/tileServers/useTileServers.ts b/react/src/hooks/tileServers/useTileServers.ts index 7bd0b80b..0a500f91 100644 --- a/react/src/hooks/tileServers/useTileServers.ts +++ b/react/src/hooks/tileServers/useTileServers.ts @@ -12,6 +12,8 @@ interface UsePostTileServerParams { projectId: number; } +export const KEY_USE_TILE_SERVERS = 'useGetTileServers'; + export interface UseDeleteTileServerParams { projectId: number; tileLayerId: number; @@ -30,7 +32,7 @@ export const useGetTileServers = ({ const query = useGet({ endpoint, - key: ['useGetTileServers', { projectId, isPublicView }], + key: [KEY_USE_TILE_SERVERS, { projectId, isPublicView }], options, }); @@ -44,7 +46,7 @@ export const usePutTileServer = ({ projectId }: UsePostTileServerParams) => { options: { onSuccess: () => queryClient.invalidateQueries({ - queryKey: ['useGetTileServers'], + queryKey: [KEY_USE_TILE_SERVERS], }), }, }); @@ -66,7 +68,7 @@ export const useDeleteTileServer = ({ options: { onSuccess: () => queryClient.invalidateQueries({ - queryKey: ['useGetTileServers'], + queryKey: [KEY_USE_TILE_SERVERS], }), }, }); diff --git a/react/src/index.tsx b/react/src/index.tsx index e72bc605..db0d5d65 100644 --- a/react/src/index.tsx +++ b/react/src/index.tsx @@ -7,6 +7,7 @@ import store from './redux/store'; import { Provider } from 'react-redux'; import { queryClient } from './queryClient'; import { ConfigProvider, ThemeConfig } from 'antd'; +import { NotificationProvider } from '@hazmapper/context/NotificationProvider'; const themeConfig: ThemeConfig = { token: { @@ -43,7 +44,9 @@ ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( - + + + diff --git a/react/src/pages/MainMenu/MainMenu.tsx b/react/src/pages/MainMenu/MainMenu.tsx index 540d3b79..31daee0f 100644 --- a/react/src/pages/MainMenu/MainMenu.tsx +++ b/react/src/pages/MainMenu/MainMenu.tsx @@ -1,6 +1,4 @@ -import React, { useEffect, useState } from 'react'; -import { useLocation } from 'react-router-dom'; -import { notification } from 'antd'; +import React from 'react'; import ProjectListing from '@hazmapper/components/Projects/ProjectListing'; import styles from './layout.module.css'; import { Button } from '@tacc/core-components'; @@ -11,35 +9,8 @@ import designsafeLogo from '@hazmapper/assets/designsafe.svg'; import nheriLogo from '@hazmapper/assets/nheri.png'; const MainMenu = () => { - const location = useLocation(); - const [message, contextHolder] = notification.useNotification(); - const [onDeleteSuccess, setOnDeleteSuccess] = useState( - location.state?.onSuccess - ); - - useEffect(() => { - if (location.state?.onSuccess) { - setOnDeleteSuccess(true); - } - }, [location.state]); - - useEffect(() => { - if (onDeleteSuccess) { - message.destroy(); - message.success({ - message: 'Success', - description: 'Your map was successfully deleted.', - placement: 'bottomLeft', - }); - // Clear the state after showing notification - window.history.replaceState({}, document.title); - setOnDeleteSuccess(false); - } - }, [onDeleteSuccess, message]); - return (
- {contextHolder}
diff --git a/react/src/pages/MapProject/MapProject.tsx b/react/src/pages/MapProject/MapProject.tsx index 197a0bf5..be285035 100644 --- a/react/src/pages/MapProject/MapProject.tsx +++ b/react/src/pages/MapProject/MapProject.tsx @@ -16,6 +16,7 @@ import { useProject, useGetTileServers, useFeatureSelection, + useGeoapiNotificationsPolling, KEY_USE_FEATURES, } from '@hazmapper/hooks'; import MapProjectNavBar from '@hazmapper/components/MapProjectNavBar'; @@ -72,6 +73,11 @@ const MapProject: React.FC = ({ isPublicView = false }) => { const { projectUUID } = useParams(); const queryClient = useQueryClient(); + /*TODO: notifications are user specific and lacking additional context. See note in react/src/types/notification.ts and WG-431 */ + + /* TODO: to be replaced by a non-pulling approach via socket-io, WG-278 */ + useGeoapiNotificationsPolling(); + const { data: activeProject, isLoading, diff --git a/react/src/types/index.ts b/react/src/types/index.ts index 687efe32..651a211a 100644 --- a/react/src/types/index.ts +++ b/react/src/types/index.ts @@ -8,3 +8,4 @@ export * from './environment'; export * from './pointCloud'; export * from './task'; export * from './geoapi'; +export * from './notification'; diff --git a/react/src/types/notification.ts b/react/src/types/notification.ts new file mode 100644 index 00000000..43bb2a56 --- /dev/null +++ b/react/src/types/notification.ts @@ -0,0 +1,12 @@ +/* + * Notifications are currently user-specific and do not include details + * about affected projects or the type of project entity impacted. + * This needs to be updated; See: https://tacc-main.atlassian.net/browse/WG-431 + */ +export interface Notification { + status: 'success' | 'warning' | 'error'; + message: string; + created: string; // ISO timestamp with timezone + viewed: boolean; + id: number; +}