Compare commits

...

5 Commits

Author SHA1 Message Date
41f844b49d 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.
2026-07-25 01:36:10 +01:00
03cd3db1c7 Enhance NoteItem component with new props for body and footer visibility
- Added showBody and showFooter props to the NoteItem component to control the rendering of the note's content and footer elements.
- Refactored the component structure to conditionally display the body and footer based on the new props, improving flexibility in note presentation.
2026-07-25 01:35:41 +01:00
6471434d81 Update CountryDisplay component to improve layout consistency
- Added minWidth style to Flex and inner div elements to ensure proper alignment and prevent overflow issues in the CountryDisplay component.
2026-07-25 01:35:35 +01:00
eec1f14512 Add margin reset for preformatted text in minimal code block editor
- Introduced a margin reset for preformatted text within the minimal code block editor to ensure consistent styling and alignment.
2026-07-25 01:35:28 +01:00
2eda23fd06 Enhance CodeBlockEditor styling for minimal mode
- Added a class name 'minimal-code-block-editor' to the div in minimal mode for improved styling and potential CSS targeting.
2026-07-25 01:35:24 +01:00
7 changed files with 453 additions and 207 deletions

View File

@ -782,3 +782,7 @@ span.ant-skeleton-input.ant-skeleton-input-sm.text-skeleton {
.ant-descriptions .ant-descriptions-item-label::after {
top: 0;
}
.minimal-code-block-editor .ant-typography pre {
margin: 0;
}

View File

@ -140,7 +140,10 @@ export default function CodeBlockEditor({
return (
<>
{minimal ? (
<div style={{ maxWidth: '300px' }}>
<div
style={{ maxWidth: '300px' }}
className='minimal-code-block-editor'
>
<Link
onClick={() => {
setCodeMirrorOpen(true)

View File

@ -13,14 +13,19 @@ const CountryDisplay = ({ countryCode }) => {
}
return (
<Flex gap={'small'} align='center' className='country-display'>
<Flex
gap={'small'}
align='center'
className='country-display'
style={{ minWidth: 0 }}
>
<Flag
code={country.code}
size='middle'
hasBorder={true}
borderRadius={5}
/>
<div>
<div style={{ minWidth: 0 }}>
<Text ellipsis>{country.name}</Text>
</div>
</Flex>

View File

@ -36,6 +36,8 @@ const NoteItem = ({
showChildNotes = true,
showActions = true,
showInfo = true,
showBody = true,
showFooter = true,
largeSpacing = false
}) => {
const [childNotes, setChildNotes] = useState([])
@ -162,90 +164,96 @@ const NoteItem = ({
const noteItem = (
<>
<Flex vertical gap={'small'}>
<Flex gap={'middle'} align='start' style={{ marginBottom: '4px' }}>
<Space>
<PersonIcon />
<Text style={{ whiteSpace: 'nowrap' }}>{note.user.name}:</Text>
</Space>
<div style={{ marginTop: '1px' }}>
<MarkdownDisplay content={note.content} />
</div>
</Flex>
<Divider style={{ margin: largeSpacing ? '10px 0' : 0 }} />
<Flex wrap gap={'small'}>
{showActions && (
<>
<Dropdown
menu={{ items: dropdownItems }}
trigger={['hover']}
placement='bottomLeft'
>
<Button size='small'>Actions</Button>
</Dropdown>
<UserNotifierToggle
type='note'
size='small'
objectData={note}
disabled={false}
/>
</>
)}
<Space size={'small'} style={{ marginRight: 8 }}>
<Text type='secondary'>Type:</Text>
<Tag color={note.noteType.color} style={{ margin: 0 }}>
{note.noteType.name}
</Tag>
</Space>
<Space size={'small'} style={{ marginRight: 8 }}>
<Text type='secondary'>User ID:</Text>
<IdDisplay
longId={false}
id={note.user._id}
type={'user'}
showHyperlink={true}
/>
</Space>
{showCreatedAt && (
<Space size={'small'} style={{ marginRight: 8 }}>
<Text type='secondary'>Created At:</Text>
<TimeDisplay dateTime={note.createdAt} showSince={true} />
</Space>
)}
<Flex style={{ flexGrow: 1 }} justify='end'>
<Space size={'small'}>
{showInfo && (
<Button
icon={<InfoIcon />}
type='text'
size='small'
onClick={() => {
navigate(infoAction.url(note._id))
}}
/>
)}
{showChildNotes && (
<Button
icon={
childNotesLoading ? (
<LoadingOutlined />
) : (
<CaretLeftFilled />
)
}
size='small'
type='text'
loading={childNotesLoading}
disabled={childNotesLoading}
style={{
transform: transformValue,
transition: 'transform 0.2s ease'
}}
onClick={toggleExpand}
/>
)}
{showBody && (
<Flex gap={'middle'} align='start' style={{ marginBottom: '4px' }}>
<Space>
<PersonIcon />
<Text style={{ whiteSpace: 'nowrap' }}>{note.user.name}:</Text>
</Space>
<div style={{ marginTop: '4px' }}>
<MarkdownDisplay content={note.content} />
</div>
</Flex>
</Flex>
)}
{showFooter && showBody && (
<Divider style={{ margin: largeSpacing ? '10px 0' : 0 }} />
)}
{showFooter && (
<Flex wrap gap={'small'}>
{showActions && (
<>
<Dropdown
menu={{ items: dropdownItems }}
trigger={['hover']}
placement='bottomLeft'
>
<Button size='small'>Actions</Button>
</Dropdown>
<UserNotifierToggle
type='note'
size='small'
objectData={note}
disabled={false}
/>
</>
)}
<Space size={'small'} style={{ marginRight: 8 }}>
<Text type='secondary'>Type:</Text>
<Tag color={note.noteType.color} style={{ margin: 0 }}>
{note.noteType.name}
</Tag>
</Space>
<Space size={'small'} style={{ marginRight: 8 }}>
<Text type='secondary'>User ID:</Text>
<IdDisplay
longId={false}
id={note.user._id}
type={'user'}
showHyperlink={true}
/>
</Space>
{showCreatedAt && (
<Space size={'small'} style={{ marginRight: 8 }}>
<Text type='secondary'>Created At:</Text>
<TimeDisplay dateTime={note.createdAt} showSince={true} />
</Space>
)}
<Flex style={{ flexGrow: 1 }} justify='end'>
<Space size={'small'}>
{showInfo && (
<Button
icon={<InfoIcon />}
type='text'
size='small'
onClick={() => {
navigate(infoAction.url(note._id))
}}
/>
)}
{showChildNotes && (
<Button
icon={
childNotesLoading ? (
<LoadingOutlined />
) : (
<CaretLeftFilled />
)
}
size='small'
type='text'
loading={childNotesLoading}
disabled={childNotesLoading}
style={{
transform: transformValue,
transition: 'transform 0.2s ease'
}}
onClick={toggleExpand}
/>
)}
</Space>
</Flex>
</Flex>
)}
{isExpanded && (
<Flex vertical gap={'small'} style={{ flexGrow: 1 }}>
{childNotes.length > 0 ? (
@ -342,7 +350,9 @@ NoteItem.propTypes = {
showChildNotes: PropTypes.bool,
showActions: PropTypes.bool,
largeSpacing: PropTypes.bool,
showInfo: PropTypes.bool
showInfo: PropTypes.bool,
showBody: PropTypes.bool,
showFooter: PropTypes.bool
}
export default NoteItem

View File

@ -1,12 +1,15 @@
import { useState } from 'react'
import { useState, memo, useMemo } from 'react'
import PropTypes from 'prop-types'
import { Typography, Button, Tag, Badge, Flex, Card, Divider } from 'antd'
import {
BellOutlined,
InfoCircleOutlined,
ExclamationCircleOutlined,
CheckCircleOutlined
} from '@ant-design/icons'
Typography,
Button,
Tag,
Badge,
Flex,
Card,
Divider,
Popover
} from 'antd'
import TimeDisplay from './TimeDisplay'
import EditIcon from '../../Icons/EditIcon'
import PropertyChanges from './PropertyChanges'
@ -15,9 +18,194 @@ import ObjectDisplay from './ObjectDisplay'
import BinIcon from '../../Icons/BinIcon'
import NoteItem from './NoteItem'
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 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 = ({
notification,
onMarkAsRead,
@ -27,14 +215,16 @@ const Notification = ({
showExtraInfo = true,
inlineIcon = false,
largeSpacing = false,
showSince = true
showSince = true,
showDetails = true,
largeTitleSpacing = false
}) => {
const [deleting, setDeleting] = useState(false)
const getNotificationIcon = (type) => {
const getNotificationTagIcon = (type) => {
switch (type) {
case 'info':
return <InfoCircleOutlined />
return <InfoCircleIcon />
case 'editObject':
return <EditIcon />
case 'newNote':
@ -42,16 +232,29 @@ const Notification = ({
case 'deleteObject':
return <BinIcon />
case 'error':
return <ExclamationCircleOutlined />
return <ExclamationOctagonIcon />
case 'success':
return <CheckCircleOutlined />
return <CheckCircleIcon />
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 icon = getNotificationIcon(type)
const icon = getNotificationTagIcon(type)
switch (type) {
case 'info':
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 = () => {
if (onMarkAsRead && !notification.read) {
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 = (
<Flex align='start' gap='small'>
<Flex vertical style={{ flex: 1 }} gap='small'>
<Flex justify='space-between' align='center'>
<Flex align='center' gap='middle'>
{inlineIcon && getNotificationIcon(notification.type)}
<Badge dot={!notification.read} offset={[2, 4]}>
<Text strong={!notification.read}>{notification.title}</Text>
</Badge>
<Flex align='start' gap='small' style={{ width: '100%' }}>
<Flex vertical style={{ flex: 1, width: '100%' }} gap='12px'>
<Flex vertical gap={largeTitleSpacing ? '10px' : '7px'}>
<Flex justify='space-between' align='center'>
<Flex
align='center'
gap={largeTitleSpacing ? 'middle' : '8px'}
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>
{showDelete && (
<Button
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}
/>
)}
{notificationBody}
</Flex>
{getNotificationMessage(notification.metadata, notification.type)}
{notification.metadata &&
getMetadataDisplay(notification.metadata, notification.type)}
{showExtraInfo && (
<>
<Divider style={{ margin: largeSpacing ? '10px 0' : 0 }} />
<Flex ju stify='space-between' align='center'>
<Flex justify='space-between' align='center'>
{getNotificationTag(notification.type)}
<TimeDisplay
dateTime={notification.createdAt}
@ -238,7 +444,10 @@ const Notification = ({
size='small'
style={{
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}
styles={{ body: { padding: '10px 12px 12px 12px' } }}
@ -246,7 +455,7 @@ const Notification = ({
{content}
</Card>
) : (
<div style={{ marginTop: '-1px' }}>{content}</div>
<div style={{ marginTop: '-1px', width: '100%' }}>{content}</div>
)
}
@ -254,9 +463,16 @@ Notification.propTypes = {
notification: PropTypes.shape({
_id: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
message: PropTypes.string.isRequired,
type: PropTypes.oneOf(['info', 'warning', 'error', 'success', 'default'])
.isRequired,
type: PropTypes.oneOf([
'info',
'warning',
'error',
'success',
'default',
'editObject',
'deleteObject',
'newNote'
]).isRequired,
read: PropTypes.bool.isRequired,
createdAt: PropTypes.string.isRequired,
metadata: PropTypes.object
@ -268,7 +484,9 @@ Notification.propTypes = {
showExtraInfo: PropTypes.bool,
inlineIcon: PropTypes.bool,
largeSpacing: PropTypes.bool,
showSince: PropTypes.bool
showSince: PropTypes.bool,
showDetails: PropTypes.bool,
largeTitleSpacing: PropTypes.bool
}
export default Notification

View File

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

View File

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