Compare commits

...

2 Commits

Author SHA1 Message Date
9470adbb8a Improved notifications.
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
2026-03-01 19:21:10 +00:00
96f7713f4d Implemented emai notifications. 2026-03-01 16:55:09 +00:00
15 changed files with 517 additions and 158 deletions

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(1,0,0,1,0,-0.113382)">
<g transform="matrix(0.725404,0,0,0.725404,3.539221,11.473342)">
<path d="M43.789,56.563L11.031,56.562C3.922,56.562 0,52.672 0,45.594L0,11C0,3.922 3.906,0.031 10.516,0.031L67.422,0.031C74.547,0.031 78.469,3.922 78.469,11L78.469,39.642C76.475,37.218 73.921,35.265 71.016,33.991L71.016,11.879L54.327,26.905L60.012,32.59C57.573,32.984 55.276,33.831 53.211,35.039L49.46,31.288L45.719,34.656C43.594,36.578 41.609,37.391 39.219,37.391C36.844,37.391 34.844,36.578 32.734,34.656L28.993,31.29L11.188,49.108L11.328,49.109L43.533,49.109C43.374,50.11 43.292,51.133 43.292,52.174C43.292,53.679 43.464,55.148 43.789,56.563ZM7.453,43.594L24.132,26.915L7.453,11.906L7.453,43.594ZM12.279,7.484L36.734,29.547C37.547,30.266 38.359,30.641 39.219,30.641C40.094,30.641 40.906,30.266 41.719,29.547L66.174,7.484L12.279,7.484Z"/>
</g>
<g transform="matrix(0.692828,0,0,0.692828,-0,0.822733)">
<path d="M87.672,70C87.672,78.984 80.188,86.469 71.219,86.469C62.203,86.469 54.766,79.016 54.766,70C54.766,61 62.203,53.562 71.219,53.562C80.234,53.562 87.672,61 87.672,70ZM75.922,62.812L68.984,72.391L65.766,68.766C65.281,68.219 64.609,67.938 63.797,67.938C62.391,67.938 61.062,68.906 61.062,70.641C61.062,71.328 61.391,72.031 61.891,72.578L67.047,78.156C67.594,78.766 68.453,79 69.188,79C70.078,79 70.938,78.625 71.391,78.016L80.375,65.828C80.719,65.344 80.875,64.781 80.875,64.297C80.875,62.812 79.672,61.625 78.172,61.625C77.266,61.625 76.453,62.078 75.922,62.812Z" style="fill-rule:nonzero;"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.725404,0,0,0.725404,3.539221,11.473342)">
<path d="M11.031,56.562C3.922,56.562 0,52.672 0,45.594L0,11C0,3.922 3.906,0.031 10.516,0.031L67.422,0.031C74.547,0.031 78.469,3.922 78.469,11L78.469,45.594C78.469,52.672 74.562,56.562 67.953,56.562L11.031,56.562ZM7.453,43.594L24.132,26.915L7.453,11.906L7.453,43.594ZM67.279,49.107L49.46,31.288L45.719,34.656C43.594,36.578 41.609,37.391 39.219,37.391C36.844,37.391 34.844,36.578 32.734,34.656L28.993,31.29L11.188,49.108C11.234,49.109 11.281,49.109 11.328,49.109L67.109,49.109C67.167,49.109 67.223,49.109 67.279,49.107ZM71.016,11.879L54.327,26.905L71.016,43.594L71.016,11.879ZM12.279,7.484L36.734,29.547C37.547,30.266 38.359,30.641 39.219,30.641C40.094,30.641 40.906,30.266 41.719,29.547L66.174,7.484L12.279,7.484Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -28,6 +28,7 @@ import { NotificationProvider } from './components/Dashboard/context/Notificatio
import { ElectronProvider } from './components/Dashboard/context/ElectronContext.jsx'
import { MessageProvider } from './components/Dashboard/context/MessageContext.jsx'
import AuthCallback from './components/App/AuthCallback.jsx'
import EmailNotificationTemplate from './components/Email/EmailNotificationTemplate.jsx'
import {
ProductionRoutes,
@ -92,6 +93,10 @@ const AppContent = () => {
path='/auth/callback'
element={<AuthCallback />}
/>
<Route
path='/email/notification'
element={<EmailNotificationTemplate />}
/>
<Route
path='/dashboard'
element={

View File

@ -259,7 +259,12 @@ const DashboardNavigation = () => {
onClick={() => showSpotlight()}
/>
</KeyboardShortcut>
<Badge count={unreadCount} size='small' offset={[-4, 5]}>
<Badge
count={unreadCount}
size='small'
offset={[-5, 8]}
style={{ padding: 0, fontWeight: 600 }}
>
<KeyboardShortcut
shortcut='alt+n'
hint='ALT N'

View File

@ -22,13 +22,22 @@ import MissingPlaceholder from './MissingPlaceholder'
import NewNote from '../Management/Notes/NewNote'
import { ApiServerContext } from '../context/ApiServerContext'
import ExclamationOctagonIcon from '../../Icons/ExclamationOctagonIcon'
import UserNotifierToggle from './UserNotifierToggle'
import { AuthContext } from '../context/AuthContext'
import { getModelByName } from '../../../database/ObjectModels'
import { useNavigate } from 'react-router-dom'
const { Text } = Typography
const NoteItem = ({ note }) => {
const NoteItem = ({
note,
showCard = true,
showCreatedAt = true,
showChildNotes = true,
showActions = true,
showInfo = true,
largeSpacing = false
}) => {
const [childNotes, setChildNotes] = useState([])
const noteModel = getModelByName('note')
const infoAction = noteModel.actions.filter(
@ -151,15 +160,8 @@ const NoteItem = ({ note }) => {
})
}
return (
<Card
key={note._id}
size='small'
style={{
backgroundColor: note.noteType.color + '26',
textAlign: 'left'
}}
>
const noteItem = (
<>
<Flex vertical gap={'small'}>
<Flex gap={'middle'} align='start'>
<Space>
@ -170,15 +172,25 @@ const NoteItem = ({ note }) => {
<MarkdownDisplay content={note.content} />
</div>
</Flex>
<Divider style={{ margin: 0 }} />
<Divider style={{ margin: largeSpacing ? '10px 0' : 0 }} />
<Flex wrap gap={'small'}>
<Dropdown
menu={{ items: dropdownItems }}
trigger={['hover']}
placement='bottomLeft'
>
<Button size='small'>Actions</Button>
</Dropdown>
{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 }}>
@ -194,34 +206,44 @@ const NoteItem = ({ note }) => {
showHyperlink={true}
/>
</Space>
<Space size={'small'} style={{ marginRight: 8 }}>
<Text type='secondary'>Created At:</Text>
<TimeDisplay dateTime={note.createdAt} showSince={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'}>
<Button
icon={<InfoIcon />}
type='text'
size='small'
onClick={() => {
navigate(infoAction.url(note._id))
}}
/>
<Button
icon={
childNotesLoading ? <LoadingOutlined /> : <CaretLeftFilled />
}
size='small'
type='text'
loading={childNotesLoading}
disabled={childNotesLoading}
style={{
transform: transformValue,
transition: 'transform 0.2s ease'
}}
onClick={toggleExpand}
/>
{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>
@ -296,12 +318,33 @@ const NoteItem = ({ note }) => {
>
<Text>Are you sure you want to delete this note?</Text>
</Modal>
</>
)
return showCard ? (
<Card
key={note._id}
size='small'
style={{
backgroundColor: note.noteType.color + '26',
textAlign: 'left'
}}
>
{noteItem}
</Card>
) : (
noteItem
)
}
NoteItem.propTypes = {
note: PropTypes.object.isRequired
note: PropTypes.object.isRequired,
showCard: PropTypes.bool,
showCreatedAt: PropTypes.bool,
showChildNotes: PropTypes.bool,
showActions: PropTypes.bool,
largeSpacing: PropTypes.bool,
showInfo: PropTypes.bool
}
export default NoteItem

View File

@ -13,6 +13,8 @@ import PropertyChanges from './PropertyChanges'
import XMarkIcon from '../../Icons/XMarkIcon'
import ObjectDisplay from './ObjectDisplay'
import BinIcon from '../../Icons/BinIcon'
import NoteItem from './NoteItem'
import PlusIcon from '../../Icons/PlusIcon'
const { Text, Paragraph } = Typography
@ -23,7 +25,9 @@ const Notification = ({
showCard = true,
showDelete = true,
showExtraInfo = true,
inlineIcon = false
inlineIcon = false,
largeSpacing = false,
showSince = true
}) => {
const [deleting, setDeleting] = useState(false)
@ -33,6 +37,8 @@ const Notification = ({
return <InfoCircleOutlined />
case 'editObject':
return <EditIcon />
case 'newNote':
return <PlusIcon />
case 'deleteObject':
return <BinIcon />
case 'error':
@ -65,6 +71,12 @@ const Notification = ({
Delete
</Tag>
)
case 'newNote':
return (
<Tag color='green' icon={icon} style={{ margin: 0 }}>
Note
</Tag>
)
case 'error':
return (
<Tag color='red' icon={icon} style={{ margin: 0 }}>
@ -108,7 +120,7 @@ const Notification = ({
expandable: true,
symbol: 'Show more'
},
style: { margin: 0 }
style: { margin: largeSpacing ? '10px 0 0 0' : 0 }
}
switch (type) {
case 'editObject': {
@ -116,7 +128,7 @@ const Notification = ({
<Paragraph {...paragraph}>
Object:
<ObjectDisplay
object={metadata.object}
object={{ ...metadata?.object, _id: metadata?.object?._id }}
objectType={metadata.objectType}
showHyperlink={true}
showSpotlight={true}
@ -131,6 +143,21 @@ const Notification = ({
</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>
}
@ -192,10 +219,13 @@ const Notification = ({
getMetadataDisplay(notification.metadata, notification.type)}
{showExtraInfo && (
<>
<Divider style={{ margin: 0 }} />
<Divider style={{ margin: largeSpacing ? '10px 0' : 0 }} />
<Flex justify='space-between' align='center'>
{getNotificationTag(notification.type)}
<TimeDisplay dateTime={notification.createdAt} showSince={true} />
<TimeDisplay
dateTime={notification.createdAt}
showSince={showSince}
/>
</Flex>
</>
)}
@ -236,7 +266,9 @@ Notification.propTypes = {
showCard: PropTypes.bool,
showDelete: PropTypes.bool,
showExtraInfo: PropTypes.bool,
inlineIcon: PropTypes.bool
inlineIcon: PropTypes.bool,
largeSpacing: PropTypes.bool,
showSince: PropTypes.bool
}
export default Notification

View File

@ -1,5 +1,6 @@
import PropTypes from 'prop-types'
import { Typography, Flex, Badge, Tag, Popover } from 'antd'
import { LoadingOutlined } from '@ant-design/icons'
import { useState, useEffect, useContext, useCallback, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { getModelByName } from '../../../database/ObjectModels'
@ -18,6 +19,7 @@ const ObjectDisplay = ({
showSpotlight = true
}) => {
const [objectData, setObjectData] = useState(object)
const [isHydrating, setIsHydrating] = useState(false)
const { subscribeToObjectUpdates, connected, fetchSpotlightData } =
useContext(ApiServerContext)
const { token } = useContext(AuthContext)
@ -44,7 +46,12 @@ const ObjectDisplay = ({
const model = getModelByName(objectType)
const spotlightQuery = `${model.prefix}:${obj._id}`
const spotlightResult = await fetchSpotlightData(spotlightQuery)
if (spotlightResult && typeof spotlightResult === 'object') {
// Spotlight returns [] when not found; only use single-object results
if (
spotlightResult &&
typeof spotlightResult === 'object' &&
!Array.isArray(spotlightResult)
) {
return spotlightResult
}
} catch (err) {
@ -57,7 +64,7 @@ const ObjectDisplay = ({
// Subscribe to object updates when component mounts
useEffect(() => {
if (object?._id && objectType && connected && token) {
if (object?._id && objectType && connected && token != null) {
const objectUpdatesUnsubscribe = subscribeToObjectUpdates(
object._id,
objectType,
@ -79,22 +86,49 @@ const ObjectDisplay = ({
// Update local state when object prop changes
useEffect(() => {
if (idRef.current == object?._id) return
idRef.current = object?._id
if (token == null) return
const isMinimal = isMinimalObject(object)
// Only skip re-fetch when we have a minimal object and already hydrated this id
if (isMinimal && idRef.current === object?._id) return
let cancelled = false
if (isMinimal) setIsHydrating(true)
const hydrateObject = async () => {
const fullObject = await fetchFullObjectIfNeeded(object)
if (!cancelled) setObjectData(fullObject)
if (!cancelled) {
setObjectData((prev) => merge({}, prev, fullObject))
if (isMinimal) idRef.current = object?._id
setIsHydrating(false)
}
}
hydrateObject()
return () => {
cancelled = true
setIsHydrating(false)
}
}, [object, fetchFullObjectIfNeeded])
}, [object, fetchFullObjectIfNeeded, isMinimalObject, token])
if (!objectData) {
return <Text type='secondary'>n/a</Text>
}
if (isHydrating) {
return (
<Tag
style={{
margin: 0,
border: 'none',
minWidth: 0,
maxWidth: '100%'
}}
className='object-display-tag'
>
<Flex gap='4px' align='center' style={{ minWidth: 0, height: '24px' }}>
<LoadingOutlined spin />
<Text type='secondary'>Loading...</Text>
</Flex>
</Tag>
)
}
const model = getModelByName(objectType)
const Icon = model.icon
const prefix = model.prefix

View File

@ -1,9 +1,10 @@
import PropTypes from 'prop-types'
import { useState, useEffect, useContext } from 'react'
import { useState, useEffect, useContext, useRef, useCallback, useMemo, memo } from 'react'
import { Button, message, Popover, Typography, Space, Flex } from 'antd'
import { UserOutlined } from '@ant-design/icons'
import BellIcon from '../../Icons/BellIcon'
import NewMailIcon from '../../Icons/NewMailIcon'
import MailCheckIcon from '../../Icons/MailCheckIcon'
import MailIcon from '../../Icons/MailIcon'
import { ApiServerContext } from '../context/ApiServerContext'
import { AuthContext } from '../context/AuthContext'
import { LoadingOutlined } from '@ant-design/icons'
@ -11,9 +12,10 @@ import InfoCircleIcon from '../../Icons/InfoCircleIcon'
const { Text } = Typography
const UserNotifierToggle = ({
const UserNotifierToggle = memo(({
type,
objectData,
size = 'middle',
disabled = false,
...buttonProps
}) => {
@ -23,7 +25,7 @@ const UserNotifierToggle = ({
fetchUserNotifiersForObject,
fetchAllUserNotifiersForObject
} = useContext(ApiServerContext)
const { userProfile } = useContext(AuthContext)
const { userProfile, token, authInitialized } = useContext(AuthContext)
const [isNotifying, setIsNotifying] = useState(false)
const [loading, setLoading] = useState(false)
const [initialLoad, setInitialLoad] = useState(true)
@ -33,14 +35,24 @@ const UserNotifierToggle = ({
const [emailTogglingId, setEmailTogglingId] = useState(null)
const objectId = objectData?._id
const authReady = Boolean(token && authInitialized)
const apiRef = useRef({
fetchUserNotifiersForObject,
fetchAllUserNotifiersForObject
})
apiRef.current = {
fetchUserNotifiersForObject,
fetchAllUserNotifiersForObject
}
useEffect(() => {
const loadNotifierState = async () => {
if (!objectId || !type) return
if (!authReady || !objectId || !type) return
const loadNotifierState = async () => {
setInitialLoad(true)
try {
const { data } = await fetchUserNotifiersForObject(objectId, type)
const { data } = await apiRef.current.fetchUserNotifiersForObject(objectId, type)
setIsNotifying(data?.length > 0)
} catch (error) {
console.error('Error fetching user notifier state:', error)
@ -50,15 +62,15 @@ const UserNotifierToggle = ({
}
loadNotifierState()
}, [objectId, type, fetchUserNotifiersForObject])
}, [authReady, objectId, type])
useEffect(() => {
const loadAllNotifiers = async () => {
if (!objectId || !type || !popoverOpen) return
if (!authReady || !objectId || !type || !popoverOpen) return
const loadAllNotifiers = async () => {
setPopoverLoading(true)
try {
const { data } = await fetchAllUserNotifiersForObject(objectId, type)
const { data } = await apiRef.current.fetchAllUserNotifiersForObject(objectId, type)
setAllNotifiers(data || [])
} catch (error) {
console.error('Error fetching all user notifiers:', error)
@ -69,10 +81,10 @@ const UserNotifierToggle = ({
}
loadAllNotifiers()
}, [objectId, type, popoverOpen, fetchAllUserNotifiersForObject])
}, [authReady, objectId, type, popoverOpen])
const handleClick = async () => {
if (!objectId || !type || loading) return
const handleClick = useCallback(async () => {
if (!authReady || !objectId || !type || loading) return
setLoading(true)
try {
@ -93,9 +105,9 @@ const UserNotifierToggle = ({
} finally {
setLoading(false)
}
}
}, [authReady, objectId, type, loading, popoverOpen, toggleUserNotifier, fetchAllUserNotifiersForObject])
const getUserDisplayName = (user) => {
const getUserDisplayName = useCallback((user) => {
if (!user) return 'Unknown'
return (
user.name ||
@ -104,11 +116,11 @@ const UserNotifierToggle = ({
user.email ||
'Unknown'
)
}
}, [])
const isCurrentUser = (user) => user?._id === userProfile?._id
const isCurrentUser = useCallback((user) => user?._id === userProfile?._id, [userProfile?._id])
const handleEmailToggle = async (item) => {
const handleEmailToggle = useCallback(async (item) => {
if (!isCurrentUser(item.user) || emailTogglingId) return
setEmailTogglingId(item._id)
const newEmail = !item.email
@ -132,67 +144,74 @@ const UserNotifierToggle = ({
} finally {
setEmailTogglingId(null)
}
}
}, [isCurrentUser, emailTogglingId, editUserNotifier])
const popoverContent = (
<Flex
vertical
justify='center'
style={{ minWidth: 240, minHeight: 25 }}
gap={'4px'}
>
{popoverLoading ? (
<Space size={'small'}>
<LoadingOutlined />
<Text style={{ margin: 0 }}>Loading, please wait...</Text>
</Space>
) : allNotifiers.length === 0 ? (
<Space size={'small'}>
<Text style={{ margin: 0 }} type='secondary'>
<InfoCircleIcon />
</Text>
<Text style={{ margin: 0 }} type='secondary'>
No users subscribed.
</Text>
</Space>
) : (
<>
{[...allNotifiers]
.sort(
(a, b) =>
(isCurrentUser(b.user) ? 1 : 0) -
(isCurrentUser(a.user) ? 1 : 0)
)
.map((item) => (
<Flex key={item._id} justify='space-between' align='center'>
<Flex align='center' gap={'6px'}>
<UserOutlined />
<Text>{getUserDisplayName(item.user)}</Text>
{isCurrentUser(item.user) && (
<Text type='secondary'> (you)</Text>
)}
const popoverContent = useMemo(
() => (
<Flex
vertical
justify='center'
style={{ minWidth: 240, minHeight: 25 }}
gap={'4px'}
>
{popoverLoading ? (
<Space size={'small'}>
<LoadingOutlined />
<Text style={{ margin: 0 }}>Loading, please wait...</Text>
</Space>
) : allNotifiers.length === 0 ? (
<Space size={'small'}>
<Text style={{ margin: 0 }} type='secondary'>
<InfoCircleIcon />
</Text>
<Text style={{ margin: 0 }} type='secondary'>
No users subscribed.
</Text>
</Space>
) : (
<>
{[...allNotifiers]
.sort(
(a, b) =>
(isCurrentUser(b.user) ? 1 : 0) -
(isCurrentUser(a.user) ? 1 : 0)
)
.map((item) => (
<Flex key={item._id} justify='space-between' align='center'>
<Flex align='center' gap={'6px'}>
<UserOutlined />
<Text>{getUserDisplayName(item.user)}</Text>
{isCurrentUser(item.user) && (
<Text type='secondary'> (you)</Text>
)}
</Flex>
<Space size={'small'}>
<Button
type='text'
icon={
item.email ? (
<MailCheckIcon
style={{
color: 'var(--color-primary)'
}}
/>
) : (
<MailIcon />
)
}
size='small'
disabled={!isCurrentUser(item.user)}
loading={emailTogglingId === item._id}
onClick={() => handleEmailToggle(item)}
/>
</Space>
</Flex>
<Space size={'small'}>
<Button
type='text'
icon={
<NewMailIcon
style={{
color: item.email ? 'var(--color-primary)' : undefined
}}
/>
}
size='small'
disabled={!isCurrentUser(item.user)}
loading={emailTogglingId === item._id}
onClick={() => handleEmailToggle(item)}
/>
</Space>
</Flex>
))}
</>
)}
</Flex>
))}
</>
)}
</Flex>
),
[popoverLoading, allNotifiers, emailTogglingId, isCurrentUser, getUserDisplayName, handleEmailToggle]
)
return (
@ -204,7 +223,7 @@ const UserNotifierToggle = ({
arrow={false}
open={popoverOpen}
onOpenChange={setPopoverOpen}
styles={{ body: { padding: '10px 15px' } }}
styles={{ body: { padding: '10px 12.5px 10px 15px' } }}
>
<Button
{...buttonProps}
@ -215,18 +234,23 @@ const UserNotifierToggle = ({
}}
/>
}
disabled={disabled || loading || initialLoad}
loading={loading || !objectId || !type}
disabled={disabled || loading || initialLoad || !authReady}
loading={loading || !authReady || !objectId || !type}
onClick={handleClick}
size={size}
style={{ minWidth: size === 'small' ? 24 : undefined }}
/>
</Popover>
)
}
})
UserNotifierToggle.displayName = 'UserNotifierToggle'
UserNotifierToggle.propTypes = {
type: PropTypes.string.isRequired,
objectData: PropTypes.object.isRequired,
disabled: PropTypes.bool
disabled: PropTypes.bool,
size: PropTypes.string
}
export default UserNotifierToggle

View File

@ -20,6 +20,11 @@ import loglevel from 'loglevel'
const logger = loglevel.getLogger('ApiServerContext')
logger.setLevel(config.logLevel)
const SPOTLIGHT_CACHE_TTL_MS = 10_000
const spotlightCache = new Map()
const runningSpotlightFetches = new Map()
const ApiServerContext = createContext()
const ApiServerProvider = ({ children }) => {
@ -974,24 +979,43 @@ const ApiServerProvider = ({ children }) => {
}
const fetchSpotlightData = async (query) => {
logger.debug('Fetching spotlight data with query:', query)
try {
const response = await axios.get(
`${config.backendUrl}/spotlight/${query}`,
{
headers: {
Accept: 'application/json',
Authorization: `Bearer ${token}`
}
}
)
return response.data
} catch (err) {
console.error(err)
showError(err, () => {
fetchSpotlightData(query)
})
const cached = spotlightCache.get(query)
if (cached && Date.now() - cached.timestamp < SPOTLIGHT_CACHE_TTL_MS) {
logger.debug('Returning cached spotlight data for query:', query)
return cached.data
}
const existing = runningSpotlightFetches.get(query)
if (existing) {
return existing
}
const fetchPromise = (async () => {
try {
logger.debug('Fetching spotlight data with query:', query)
const response = await axios.get(
`${config.backendUrl}/spotlight/${query}`,
{
headers: {
Accept: 'application/json',
Authorization: `Bearer ${token}`
}
}
)
spotlightCache.set(query, { data: response.data, timestamp: Date.now() })
return response.data
} catch (err) {
console.error(err)
showError(err, () => {
fetchSpotlightData(query)
})
} finally {
runningSpotlightFetches.delete(query)
}
})()
runningSpotlightFetches.set(query, fetchPromise)
return fetchPromise
}
const getModelStats = async (objectType) => {

View File

@ -297,6 +297,7 @@ const AuthProvider = ({ children }) => {
setToken(nextToken)
setExpiresAt(nextExpiresAt)
setUserProfile(nextUser)
setAuthenticated(true)
// Persist session (cookies on web, keytar on electron)
const persisted = await persistSession({
@ -619,6 +620,7 @@ const AuthProvider = ({ children }) => {
<AuthContext.Provider
value={{
authenticated,
authInitialized: retreivedTokenFromCookies,
setUnauthenticated,
loginWithSSO,
getLoginToken,

View File

@ -5,6 +5,7 @@ import {
useCallback,
useEffect
} from 'react'
import { useLocation } from 'react-router-dom'
import { notification, Drawer } from 'antd'
import PropTypes from 'prop-types'
import { AuthContext } from './AuthContext'
@ -16,6 +17,7 @@ const NotificationContext = createContext()
const NotificationProvider = ({ children }) => {
const [api, contextHolder] = notification.useNotification()
const location = useLocation()
const { authenticated } = useContext(AuthContext)
const {
showError,
@ -119,6 +121,10 @@ const NotificationProvider = ({ children }) => {
}
}, [authenticated, fetchNotifications])
useEffect(() => {
setNotificationCenterVisible(false)
}, [location.pathname])
useEffect(() => {
if (!authenticated || !registerNotificationListener) return
const handleNotification = (notif) => {

View File

@ -0,0 +1,19 @@
#email-notification-root {
padding: 10% 20px;
min-height: 100vh;
background: #ffffff;
}
.email-notification-card {
border-radius: 25px;
margin: 0 auto;
max-width: 600px;
}
.email-notification-card-actions {
margin-top: 55px;
}
.email-notification-card-footer {
margin-top: 75px;
}

View File

@ -0,0 +1,134 @@
import { useSearchParams } from 'react-router-dom'
import './EmailNotificationTemplate.css'
import { Button, Card, ConfigProvider, Flex, theme, Typography } from 'antd'
import FarmControlLogo from '../Logos/FarmControlLogo'
import Notification from '../Dashboard/common/Notification'
import { useThemeContext } from '../Dashboard/context/ThemeContext'
import { getModelByName } from '../../database/ObjectModels'
const { Text } = Typography
/**
* Email notification template - renders notification data for server-side HTML capture.
* Used by the API's sendEmailNotification with Puppeteer.
* Params: title, message, type, metadata (JSON string)
*/
const EmailNotificationTemplate = () => {
const { themeConfig } = useThemeContext()
const [searchParams] = useSearchParams()
const title = searchParams.get('title') || 'Notification'
const message = searchParams.get('message') || ''
const type = searchParams.get('type') || 'info'
const read = searchParams.get('read') || false
const email = searchParams.get('email') || ''
const createdAt = searchParams.get('createdAt') || new Date()
const updatedAt = searchParams.get('updatedAt') || new Date()
const origin = window.location.origin
let metadata = {}
try {
const metaStr = searchParams.get('metadata')
if (metaStr) metadata = JSON.parse(metaStr)
} catch {
// ignore parse errors
}
const lightThemeConfig = {
...themeConfig,
algorithm: theme.defaultAlgorithm,
components: {
...themeConfig.components,
Layout: { headerBg: '#ffffff' }
}
}
const getNotifictionActions = () => {
switch (type) {
case 'editObject': {
const model = getModelByName(metadata?.objectType)
const infoAction = model.actions?.find(
(action) => action.name === 'info'
)
const url = infoAction ? infoAction.url(metadata?.object?._id) : '#'
return (
<Button href={`${origin}${url}`} type='primary'>
View in Dashboard
</Button>
)
}
case 'newNote': {
console.log('newNote metadata', JSON.stringify(metadata, null, 2))
const model = getModelByName(metadata?.note?.parentType)
const infoAction =
model.actions?.find((action) => action.name === 'info') ?? null
const url = infoAction
? infoAction.url(metadata?.note?.parent?._id)
: null
return url ? (
<Button href={`${origin}${url}`} type='primary'>
View in Dashboard
</Button>
) : null
}
}
return null
}
return (
<ConfigProvider theme={lightThemeConfig}>
<div id='email-notification-root' data-rendered='true'>
<FarmControlLogo
style={{
fontSize: '500px',
height: '40px',
display: 'flex',
justifyContent: 'center',
margin: '0 auto 60px 0'
}}
/>
<Card className='email-notification-card'>
<Notification
showCard={false}
showDelete={false}
inlineIcon={false}
largeSpacing={true}
showSince={false}
notification={{
title: title,
message: message,
type: type,
metadata: metadata,
read: read,
createdAt: createdAt,
updatedAt: updatedAt
}}
/>
</Card>
<Flex justify='center' className='email-notification-card-actions'>
{getNotifictionActions()}
</Flex>
{email && (
<Flex
align='center'
className='email-notification-card-footer'
vertical
>
<Text
type='secondary'
style={{
fontSize: '12px',
maxWidth: '300px',
textAlign: 'center'
}}
>
This email was sent to {email}. Please do not reply to this email.
</Text>
</Flex>
)}
</div>
</ConfigProvider>
)
}
export default EmailNotificationTemplate

View File

@ -0,0 +1,6 @@
import Icon from '@ant-design/icons'
import CustomIconSvg from '../../../assets/icons/mailcheckicon.svg?react'
const MailCheckIcon = (props) => <Icon component={CustomIconSvg} {...props} />
export default MailCheckIcon

View File

@ -0,0 +1,6 @@
import Icon from '@ant-design/icons'
import CustomIconSvg from '../../../assets/icons/mailicon.svg?react'
const MailIcon = (props) => <Icon component={CustomIconSvg} {...props} />
export default MailIcon