Skip to content

Commit

Permalink
Task/WG-422 poll notifications (#320)
Browse files Browse the repository at this point in the history
* Allow user to set which DS site to use during local development

* Update example

* Improve comment

* Add hook/context for notifcations

* Use notification when adding feature file

* Add polling

* Improve polling and invalidate cache

* Rework notfication for deleting project due to export const NotificationProvider/NotificationContext

* Fix refreshing of features

* Use notication hook and unify config options

* Remove unused imports

* Fix comment

* Fix repeated notifications

* Remove exclamation point at end of message
  • Loading branch information
nathanfranklin authored Feb 14, 2025
1 parent 6d52a02 commit 771a7ef
Show file tree
Hide file tree
Showing 14 changed files with 235 additions and 75 deletions.
33 changes: 24 additions & 9 deletions react/src/components/AssetsPanel/AssetsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -87,17 +91,28 @@ const AssetsPanel: React.FC<Props> = ({
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;
Expand All @@ -106,7 +121,7 @@ const AssetsPanel: React.FC<Props> = ({
<>
<Flex vertical className={styles.root} flex={1}>
<Header className={styles.topSection}>
<Button onClick={toggleModal} icon={<PlusOutlined />}>
<Button onClick={() => setIsModalOpen(true)} icon={<PlusOutlined />}>
Import from DesignSafe
</Button>
</Header>
Expand All @@ -126,7 +141,7 @@ const AssetsPanel: React.FC<Props> = ({
</Flex>
<FileBrowserModal
isOpen={isModalOpen}
toggle={toggleModal}
toggle={() => setIsModalOpen(false)}
onImported={handleFileImport}
allowedFileExtensions={IMPORTABLE_FEATURE_TYPES}
/>
Expand Down
28 changes: 10 additions & 18 deletions react/src/components/DeleteMapModal/DeleteMapModal.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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();
};
Expand All @@ -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.',
});
},
}
);
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -17,7 +17,7 @@ const ManageMapProjectPanel: React.FC<ManageMapProjectModalProps> = ({
project: activeProject,
}) => {
const [activeKey, setActiveKey] = useState('1');
const [updateApi, contextHolder] = notification.useNotification();
const notification = useNotification();

const { mutate, isPending } = useUpdateProjectInfo();

Expand All @@ -30,21 +30,13 @@ const ManageMapProjectPanel: React.FC<ManageMapProjectModalProps> = ({

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,
});
},
});
Expand Down Expand Up @@ -99,7 +91,6 @@ const ManageMapProjectPanel: React.FC<ManageMapProjectModalProps> = ({

return (
<Flex vertical className={styles.root}>
{contextHolder}
<Tabs
type="card"
size="small"
Expand Down
59 changes: 59 additions & 0 deletions react/src/context/NotificationProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import React from 'react';
import { notification } from 'antd';
import type { ArgsProps } from 'antd/es/notification';
import { v4 as uuidv4 } from 'uuid';

// takes antd's ArgProps or just a description and optional message
type NotificationConfig = ArgsProps | { description: string; message?: string };

type NotificationAPI = {
success: (config: NotificationConfig) => 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<NotificationAPI>({
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<ArgsProps> = {
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 (
<NotificationContext.Provider value={notificationApi}>
{contextHolder}
{children}
</NotificationContext.Provider>
);
};
1 change: 1 addition & 0 deletions react/src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ export * from './projects';
export * from './user';
export * from './map';
export * from './pointClouds';
export * from './notifications';
2 changes: 2 additions & 0 deletions react/src/hooks/notifications/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { useNotification } from './useNotification';
export { useGeoapiNotificationsPolling } from './useGeoapiNotificationsPolling';
90 changes: 90 additions & 0 deletions react/src/hooks/notifications/useGeoapiNotificationsPolling.ts
Original file line number Diff line number Diff line change
@@ -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<Notification[]>({
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,
};
};
15 changes: 15 additions & 0 deletions react/src/hooks/notifications/useNotification.ts
Original file line number Diff line number Diff line change
@@ -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;
};
8 changes: 5 additions & 3 deletions react/src/hooks/tileServers/useTileServers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ interface UsePostTileServerParams {
projectId: number;
}

export const KEY_USE_TILE_SERVERS = 'useGetTileServers';

export interface UseDeleteTileServerParams {
projectId: number;
tileLayerId: number;
Expand All @@ -30,7 +32,7 @@ export const useGetTileServers = ({

const query = useGet<TileServerLayer[]>({
endpoint,
key: ['useGetTileServers', { projectId, isPublicView }],
key: [KEY_USE_TILE_SERVERS, { projectId, isPublicView }],
options,
});

Expand All @@ -44,7 +46,7 @@ export const usePutTileServer = ({ projectId }: UsePostTileServerParams) => {
options: {
onSuccess: () =>
queryClient.invalidateQueries({
queryKey: ['useGetTileServers'],
queryKey: [KEY_USE_TILE_SERVERS],
}),
},
});
Expand All @@ -66,7 +68,7 @@ export const useDeleteTileServer = ({
options: {
onSuccess: () =>
queryClient.invalidateQueries({
queryKey: ['useGetTileServers'],
queryKey: [KEY_USE_TILE_SERVERS],
}),
},
});
Expand Down
Loading

0 comments on commit 771a7ef

Please sign in to comment.