Enhance Notification component with new features and improved structure
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good

- Introduced memoization for NotificationBody and NotificationDetails components to optimize rendering performance.
- Added new notification types and corresponding message handling in getNotificationMessage function for better user feedback.
- Updated Notification component to support inline icons and detailed views for notifications.
- Refactored notification icon handling to utilize model-specific icons where applicable.
- Adjusted NotificationCenter to display notifications with new props for enhanced visibility and interaction.
- Increased notification duration for better user experience.
This commit is contained in:
Tom Butcher 2026-07-25 01:36:10 +01:00
parent 03cd3db1c7
commit 41f844b49d
3 changed files with 345 additions and 121 deletions

View File

@ -1,12 +1,15 @@
import { useState } from 'react' import { useState, memo, useMemo } from 'react'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { Typography, Button, Tag, Badge, Flex, Card, Divider } from 'antd'
import { import {
BellOutlined, Typography,
InfoCircleOutlined, Button,
ExclamationCircleOutlined, Tag,
CheckCircleOutlined Badge,
} from '@ant-design/icons' Flex,
Card,
Divider,
Popover
} from 'antd'
import TimeDisplay from './TimeDisplay' import TimeDisplay from './TimeDisplay'
import EditIcon from '../../Icons/EditIcon' import EditIcon from '../../Icons/EditIcon'
import PropertyChanges from './PropertyChanges' import PropertyChanges from './PropertyChanges'
@ -15,9 +18,194 @@ import ObjectDisplay from './ObjectDisplay'
import BinIcon from '../../Icons/BinIcon' import BinIcon from '../../Icons/BinIcon'
import NoteItem from './NoteItem' import NoteItem from './NoteItem'
import PlusIcon from '../../Icons/PlusIcon' import PlusIcon from '../../Icons/PlusIcon'
import InfoCircleIcon from '../../Icons/InfoCircleIcon'
import ExclamationOctagonIcon from '../../Icons/ExclamationOctagonIcon'
import CheckCircleIcon from '../../Icons/CheckCircleIcon'
import BellIcon from '../../Icons/BellIcon'
import { getModelByName } from '../../../database/ObjectModels'
const { Text, Paragraph } = Typography const { Text, Paragraph } = Typography
const getNotificationMessage = (type, metadata) => {
const objectType = metadata?.objectType ?? metadata?.note?.parentType
const model = objectType ? getModelByName(objectType) : null
const objectLabel = model?.label?.toLowerCase() ?? 'object'
switch (type) {
case 'editObject':
return `A ${objectLabel} was updated.`
case 'deleteObject':
return `A ${objectLabel} was deleted.`
case 'newNote':
return 'A new note was added.'
case 'info':
return 'You have a new notification.'
case 'error':
return 'An error occurred.'
case 'success':
return 'Action completed successfully.'
default:
return 'You have a new notification.'
}
}
const getParagraphProps = (largeSpacing) => ({
ellipsis: {
rows: 2,
expandable: true,
symbol: 'Show more'
},
style: {
margin: largeSpacing ? '10px 0 0 0' : 0,
lineHeight: 1.7,
marginBottom: 2
}
})
const NotificationBody = memo(function NotificationBody({
type,
message,
metadata,
largeSpacing
}) {
const paragraph = getParagraphProps(largeSpacing)
switch (type) {
case 'editObject':
if (metadata?.old || metadata?.new) {
return (
<PropertyChanges
type={metadata?.objectType ?? 'unknown'}
value={{
old: metadata.old,
new: metadata.new
}}
/>
)
}
return <Paragraph {...paragraph}>{message}</Paragraph>
case 'newNote':
return (
<NoteItem
note={metadata?.note}
showCard={false}
showCreatedAt={false}
showChildNotes={false}
showActions={false}
showInfo={false}
showBody={true}
showFooter={false}
largeSpacing={largeSpacing}
/>
)
case 'deleteObject':
return <Paragraph {...paragraph}>{message}</Paragraph>
case 'info':
case 'error':
case 'success':
return <Paragraph {...paragraph}>{message}</Paragraph>
default:
return <Paragraph {...paragraph}>{message}</Paragraph>
}
})
NotificationBody.propTypes = {
type: PropTypes.string,
message: PropTypes.string,
metadata: PropTypes.object,
largeSpacing: PropTypes.bool
}
const NotificationDetails = memo(function NotificationDetails({
type,
message,
metadata,
largeSpacing
}) {
const paragraph = getParagraphProps(largeSpacing)
const object = metadata?.object
const user = metadata?.user
const objectType = metadata?.objectType
const note = metadata?.note
switch (type) {
case 'editObject':
return (
<Flex align='center' gap='small'>
<Text {...paragraph} type='secondary'>
Object:
</Text>
<ObjectDisplay
object={object}
objectType={objectType}
showHyperlink={true}
showSpotlight={true}
/>
<Text {...paragraph} style={{ marginLeft: '5px' }} type='secondary'>
User:
</Text>
<ObjectDisplay
object={user}
objectType='user'
showHyperlink={true}
showSpotlight={true}
/>
</Flex>
)
case 'newNote':
return (
<Paragraph {...paragraph}>
<NoteItem
note={note}
showCard={false}
showCreatedAt={false}
showChildNotes={false}
showActions={false}
showInfo={false}
showBody={false}
largeSpacing={largeSpacing}
/>
</Paragraph>
)
case 'deleteObject':
return (
<Flex align='center' gap='small'>
<Text {...paragraph} type='secondary'>
Object:
</Text>
<ObjectDisplay
object={object}
objectType={objectType}
showHyperlink={true}
showSpotlight={true}
/>
<Text {...paragraph} style={{ marginLeft: '5px' }} type='secondary'>
User:
</Text>
<ObjectDisplay
object={user}
objectType='user'
showHyperlink={true}
showSpotlight={true}
/>
</Flex>
)
case 'info':
case 'error':
case 'success':
return <Paragraph {...paragraph}>{message}</Paragraph>
default:
return <Paragraph {...paragraph}>{message}</Paragraph>
}
})
NotificationDetails.propTypes = {
type: PropTypes.string,
message: PropTypes.string,
metadata: PropTypes.object,
largeSpacing: PropTypes.bool
}
const Notification = ({ const Notification = ({
notification, notification,
onMarkAsRead, onMarkAsRead,
@ -27,14 +215,16 @@ const Notification = ({
showExtraInfo = true, showExtraInfo = true,
inlineIcon = false, inlineIcon = false,
largeSpacing = false, largeSpacing = false,
showSince = true showSince = true,
showDetails = true,
largeTitleSpacing = false
}) => { }) => {
const [deleting, setDeleting] = useState(false) const [deleting, setDeleting] = useState(false)
const getNotificationIcon = (type) => { const getNotificationTagIcon = (type) => {
switch (type) { switch (type) {
case 'info': case 'info':
return <InfoCircleOutlined /> return <InfoCircleIcon />
case 'editObject': case 'editObject':
return <EditIcon /> return <EditIcon />
case 'newNote': case 'newNote':
@ -42,16 +232,29 @@ const Notification = ({
case 'deleteObject': case 'deleteObject':
return <BinIcon /> return <BinIcon />
case 'error': case 'error':
return <ExclamationCircleOutlined /> return <ExclamationOctagonIcon />
case 'success': case 'success':
return <CheckCircleOutlined /> return <CheckCircleIcon />
default: default:
return <BellOutlined /> return <BellIcon />
} }
} }
const getNotificationIcon = (type, metadata) => {
if (['editObject', 'newNote', 'deleteObject'].includes(type)) {
const objectType = metadata?.objectType ?? metadata?.note?.parentType
const model = objectType ? getModelByName(objectType) : null
if (model?.icon) {
const Icon = model.icon
return <Icon style={{ fontSize: 17 }} />
}
}
return getNotificationTagIcon(type)
}
const getNotificationTag = (type) => { const getNotificationTag = (type) => {
const icon = getNotificationIcon(type) const icon = getNotificationTagIcon(type)
switch (type) { switch (type) {
case 'info': case 'info':
return ( return (
@ -98,71 +301,6 @@ const Notification = ({
} }
} }
const getMetadataDisplay = (metadata, type) => {
if (metadata.old && metadata.new && type === 'editObject') {
return (
<PropertyChanges
type={metadata?.objectType ?? 'unknown'}
value={{
old: metadata.old,
new: metadata.new
}}
/>
)
}
return null
}
const getNotificationMessage = (metadata, type) => {
const paragraph = {
ellipsis: {
rows: 2,
expandable: true,
symbol: 'Show more'
},
style: { margin: largeSpacing ? '10px 0 0 0' : 0 }
}
switch (type) {
case 'editObject': {
return (
<Paragraph {...paragraph}>
Object:
<ObjectDisplay
object={{ ...metadata?.object, _id: metadata?.object?._id }}
objectType={metadata.objectType}
showHyperlink={true}
showSpotlight={true}
/>
User:
<ObjectDisplay
object={metadata.user}
objectType='user'
showHyperlink={true}
showSpotlight={true}
/>
</Paragraph>
)
}
case 'newNote': {
return (
<Paragraph {...paragraph}>
<NoteItem
note={metadata.note}
showCard={false}
showCreatedAt={false}
showChildNotes={false}
showActions={false}
showInfo={false}
largeSpacing={largeSpacing}
/>
</Paragraph>
)
}
default:
return <Paragraph {...paragraph}>{notification.message}</Paragraph>
}
}
const handleMarkAsRead = () => { const handleMarkAsRead = () => {
if (onMarkAsRead && !notification.read) { if (onMarkAsRead && !notification.read) {
onMarkAsRead(notification._id) onMarkAsRead(notification._id)
@ -181,46 +319,114 @@ const Notification = ({
} }
} }
const message = useMemo(
() => getNotificationMessage(notification.type, notification.metadata),
[notification.type, notification.metadata]
)
const notificationDetails = useMemo(
() => (
<NotificationDetails
type={notification.type}
message={message}
metadata={notification.metadata}
largeSpacing={largeSpacing}
/>
),
[notification.type, message, notification.metadata, largeSpacing]
)
const notificationBody = useMemo(
() => (
<NotificationBody
type={notification.type}
message={message}
metadata={notification.metadata}
largeSpacing={largeSpacing}
/>
),
[notification.type, message, notification.metadata, largeSpacing]
)
const content = ( const content = (
<Flex align='start' gap='small'> <Flex align='start' gap='small' style={{ width: '100%' }}>
<Flex vertical style={{ flex: 1 }} gap='small'> <Flex vertical style={{ flex: 1, width: '100%' }} gap='12px'>
<Flex justify='space-between' align='center'> <Flex vertical gap={largeTitleSpacing ? '10px' : '7px'}>
<Flex align='center' gap='middle'> <Flex justify='space-between' align='center'>
{inlineIcon && getNotificationIcon(notification.type)} <Flex
<Badge dot={!notification.read} offset={[2, 4]}> align='center'
<Text strong={!notification.read}>{notification.title}</Text> gap={largeTitleSpacing ? 'middle' : '8px'}
</Badge> style={{ minWidth: 0 }}
>
{inlineIcon &&
getNotificationIcon(notification.type, notification.metadata)}
<Text
strong={!notification.read}
ellipsis
style={{ minWidth: 0 }}
>
{notification.title}
</Text>
</Flex>
<Flex align='center' gap='5px' style={{ marginLeft: 10 }}>
{showDetails && (
<Popover
arrow={false}
trigger={['hover', 'click']}
placement='bottomLeft'
content={
<div style={{ marginLeft: '6px' }}>
{notificationDetails}
</div>
}
>
<Button
type='text'
size='small'
onClick={handleMarkAsRead}
disabled={deleting}
icon={
<InfoCircleIcon
style={{ fontSize: 14, marginTop: 2.5, marginLeft: 0 }}
/>
}
></Button>
</Popover>
)}
{showDelete && (
<Badge
dot={!notification.read}
offset={[0, 3]}
style={{ minWidth: 0 }}
>
<Button
type='text'
size='small'
loading={deleting}
disabled={deleting}
icon={
<XMarkIcon
style={{
fontSize: 12,
marginBottom: 4.5,
marginLeft: 0
}}
/>
}
onClick={handleDelete}
/>
</Badge>
)}
</Flex>
</Flex> </Flex>
{showDelete && (
<Button {notificationBody}
type='text'
size='small'
loading={deleting}
disabled={deleting}
style={{
width: 20,
height: 20,
position: 'relative',
left: 2
}}
icon={
<XMarkIcon
style={{ fontSize: 10, marginBottom: 5, marginLeft: 0 }}
/>
}
onClick={handleDelete}
/>
)}
</Flex> </Flex>
{getNotificationMessage(notification.metadata, notification.type)}
{notification.metadata &&
getMetadataDisplay(notification.metadata, notification.type)}
{showExtraInfo && ( {showExtraInfo && (
<> <>
<Divider style={{ margin: largeSpacing ? '10px 0' : 0 }} /> <Divider style={{ margin: largeSpacing ? '10px 0' : 0 }} />
<Flex ju stify='space-between' align='center'> <Flex justify='space-between' align='center'>
{getNotificationTag(notification.type)} {getNotificationTag(notification.type)}
<TimeDisplay <TimeDisplay
dateTime={notification.createdAt} dateTime={notification.createdAt}
@ -238,7 +444,10 @@ const Notification = ({
size='small' size='small'
style={{ style={{
cursor: 'pointer', cursor: 'pointer',
transition: 'background-color 0.2s' transition: 'background-color 0.2s',
backgroundColor: notification.metadata?.note?.noteType?.color
? notification.metadata.note.noteType.color + '26'
: undefined
}} }}
onClick={handleMarkAsRead} onClick={handleMarkAsRead}
styles={{ body: { padding: '10px 12px 12px 12px' } }} styles={{ body: { padding: '10px 12px 12px 12px' } }}
@ -246,7 +455,7 @@ const Notification = ({
{content} {content}
</Card> </Card>
) : ( ) : (
<div style={{ marginTop: '-1px' }}>{content}</div> <div style={{ marginTop: '-1px', width: '100%' }}>{content}</div>
) )
} }
@ -254,9 +463,16 @@ Notification.propTypes = {
notification: PropTypes.shape({ notification: PropTypes.shape({
_id: PropTypes.string.isRequired, _id: PropTypes.string.isRequired,
title: PropTypes.string.isRequired, title: PropTypes.string.isRequired,
message: PropTypes.string.isRequired, type: PropTypes.oneOf([
type: PropTypes.oneOf(['info', 'warning', 'error', 'success', 'default']) 'info',
.isRequired, 'warning',
'error',
'success',
'default',
'editObject',
'deleteObject',
'newNote'
]).isRequired,
read: PropTypes.bool.isRequired, read: PropTypes.bool.isRequired,
createdAt: PropTypes.string.isRequired, createdAt: PropTypes.string.isRequired,
metadata: PropTypes.object metadata: PropTypes.object
@ -268,7 +484,9 @@ Notification.propTypes = {
showExtraInfo: PropTypes.bool, showExtraInfo: PropTypes.bool,
inlineIcon: PropTypes.bool, inlineIcon: PropTypes.bool,
largeSpacing: PropTypes.bool, largeSpacing: PropTypes.bool,
showSince: PropTypes.bool showSince: PropTypes.bool,
showDetails: PropTypes.bool,
largeTitleSpacing: PropTypes.bool
} }
export default Notification export default Notification

View File

@ -56,7 +56,9 @@ const NotificationCenter = ({ visible }) => {
handleMarkAllAsRead() handleMarkAllAsRead()
} else if (key === 'clearAll') { } else if (key === 'clearAll') {
setClearNotificationsLoading(true) setClearNotificationsLoading(true)
deleteAllNotifications().finally(() => setClearNotificationsLoading(false)) deleteAllNotifications().finally(() =>
setClearNotificationsLoading(false)
)
} }
} }
} }
@ -95,6 +97,8 @@ const NotificationCenter = ({ visible }) => {
<Flex vertical gap='small'> <Flex vertical gap='small'>
{notifications.map((notification) => ( {notifications.map((notification) => (
<Notification <Notification
inlineIcon={true}
showDetails={true}
key={notification._id} key={notification._id}
notification={notification} notification={notification}
onMarkAsRead={handleMarkAsRead} onMarkAsRead={handleMarkAsRead}

View File

@ -146,10 +146,12 @@ const NotificationProvider = ({ children }) => {
showDelete={false} showDelete={false}
showExtraInfo={false} showExtraInfo={false}
inlineIcon={true} inlineIcon={true}
showDetails={false}
largeTitleSpacing={true}
/> />
), ),
icon: null, icon: null,
duration: 3, duration: 5,
key: notif._id key: notif._id
}) })
} }