Big performance increase.
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good

This commit is contained in:
Tom Butcher 2026-09-19 13:07:30 +01:00
parent 24a3a03c65
commit e446098a76
18 changed files with 974 additions and 3382 deletions

View File

@ -4053,6 +4053,7 @@ body.objectKanbanColumnResizing * {
visibility: hidden; visibility: hidden;
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
content-visibility: hidden;
} }
.dashboard-tab-pane-inactive.dashboard-tab-pane-capturing { .dashboard-tab-pane-inactive.dashboard-tab-pane-capturing {
@ -4062,6 +4063,7 @@ body.objectKanbanColumnResizing * {
pointer-events: none; pointer-events: none;
transform: translate(-100%, 0); transform: translate(-100%, 0);
z-index: 0; z-index: 0;
content-visibility: visible;
} }
.dashboard-tab-preview-capture-frame { .dashboard-tab-preview-capture-frame {

545
bun.lock

File diff suppressed because it is too large Load Diff

View File

@ -128,9 +128,6 @@
"@vitejs/plugin-react": "^5.0.2", "@vitejs/plugin-react": "^5.0.2",
"concurrently": "^9.2.1", "concurrently": "^9.2.1",
"electrobun": "1.18.1", "electrobun": "1.18.1",
"electron": "^38.7.1",
"electron-builder": "^26.0.12",
"electron-packager": "^17.1.2",
"eslint": "^9.34.0", "eslint": "^9.34.0",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.4", "eslint-plugin-prettier": "^5.5.4",

2153
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -65,23 +65,22 @@ const getRouter = () => {
return BrowserRouter return BrowserRouter
} }
const renderEmpty = () => (
<div style={{ margin: '32px' }}>
<MissingPlaceholder
message='No data.'
hasBackground={false}
hasBorder={false}
/>
</div>
)
const AppContent = () => { const AppContent = () => {
const { themeConfig } = useThemeContext() const { themeConfig } = useThemeContext()
const Router = getRouter() const Router = getRouter()
return ( return (
<ConfigProvider <ConfigProvider theme={themeConfig} renderEmpty={renderEmpty}>
theme={themeConfig}
renderEmpty={() => (
<div style={{ margin: '32px' }}>
<MissingPlaceholder
message='No data.'
hasBackground={false}
hasBorder={false}
/>
</div>
)}
>
<App> <App>
<Router> <Router>
<ElectronProvider> <ElectronProvider>

View File

@ -14,7 +14,6 @@ import DeveloperSidebar from './Developer/DeveloperSidebar'
import DashboardSidebarSplitter from './common/DashboardSidebarSplitter' import DashboardSidebarSplitter from './common/DashboardSidebarSplitter'
import MobileFooterNavigation from './common/MobileFooterNavigation' import MobileFooterNavigation from './common/MobileFooterNavigation'
import { useThemeContext } from './context/ThemeContext' import { useThemeContext } from './context/ThemeContext'
import { MessageProvider } from './context/MessageContext'
import { useDashboardObjectToolsContext } from './context/DashboardObjectToolsContext' import { useDashboardObjectToolsContext } from './context/DashboardObjectToolsContext'
import { useMediaQuery } from 'react-responsive' import { useMediaQuery } from 'react-responsive'
import { ElectronContext } from './context/ElectronContext' import { ElectronContext } from './context/ElectronContext'
@ -52,35 +51,33 @@ const DashboardLayout = ({ children }) => {
) )
return ( return (
<MessageProvider> <Layout
<Layout style={{ height: 'var(--unit-100vh)' }}
style={{ height: 'var(--unit-100vh)' }} className={`${isDarkMode ? 'dark-mode' : 'light-mode'} main-layout${isMobile ? ' is-mobile' : ''}`}
className={`${isDarkMode ? 'dark-mode' : 'light-mode'} main-layout${isMobile ? ' is-mobile' : ''}`} >
> <DashboardNavigation />
<DashboardNavigation /> <DashboardSidebarSplitter sidebar={sidebar}>
<DashboardSidebarSplitter sidebar={sidebar}> <Layout
<Layout style={{ padding: '24px', height: '100%' }}
style={{ padding: '24px', height: '100%' }} className='main-content-layout'
className='main-content-layout' >
> <Content>
<Content> <Flex
<Flex vertical
vertical style={{ height: '100%' }}
style={{ height: '100%' }} gap={isElectron ? '10px' : '20px'}
gap={isElectron ? '10px' : '20px'} >
> <Flex justify='space-between' align='center'>
<Flex justify='space-between' align='center'> <DashboardBreadcrumb style={{ margin: '16px 0' }} />
<DashboardBreadcrumb style={{ margin: '16px 0' }} /> {currentObjectTools}
{currentObjectTools}
</Flex>
<div style={{ flex: 1, minHeight: 0 }}>{children}</div>
</Flex> </Flex>
</Content> <div style={{ flex: 1, minHeight: 0 }}>{children}</div>
</Layout> </Flex>
</DashboardSidebarSplitter> </Content>
{isMobile ? <MobileFooterNavigation /> : null} </Layout>
</Layout> </DashboardSidebarSplitter>
</MessageProvider> {isMobile ? <MobileFooterNavigation /> : null}
</Layout>
) )
} }

View File

@ -1,5 +1,5 @@
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' import { useCallback, useEffect, useLayoutEffect, useRef, useState, memo } from 'react'
import { Button, Flex, Popover, Typography } from 'antd' import { Button, Flex, Popover, Typography } from 'antd'
import DashboardTabPreview from './DashboardTabPreview' import DashboardTabPreview from './DashboardTabPreview'
import classNames from 'classnames' import classNames from 'classnames'
@ -66,7 +66,7 @@ DashboardTabLabel.propTypes = {
onClose: PropTypes.func onClose: PropTypes.func
} }
const DashboardTabItem = ({ const DashboardTabItem = memo(function DashboardTabItem({
tab, tab,
tabIndex, tabIndex,
isSelected, isSelected,
@ -78,9 +78,9 @@ const DashboardTabItem = ({
onKeyDown, onKeyDown,
onDragOver, onDragOver,
onDrop, onDrop,
onClose onClose,
}) => { captureTabPreview
const { captureTabPreview } = useNavigationTabs() }) {
const { const {
src: previewSrc, src: previewSrc,
theme: previewTheme, theme: previewTheme,
@ -174,7 +174,9 @@ const DashboardTabItem = ({
{tabButton} {tabButton}
</Popover> </Popover>
) )
} })
DashboardTabItem.displayName = 'DashboardTabItem'
DashboardTabItem.propTypes = { DashboardTabItem.propTypes = {
tab: PropTypes.shape({ tab: PropTypes.shape({
@ -193,7 +195,8 @@ DashboardTabItem.propTypes = {
onKeyDown: PropTypes.func.isRequired, onKeyDown: PropTypes.func.isRequired,
onDragOver: PropTypes.func.isRequired, onDragOver: PropTypes.func.isRequired,
onDrop: PropTypes.func.isRequired, onDrop: PropTypes.func.isRequired,
onClose: PropTypes.func onClose: PropTypes.func,
captureTabPreview: PropTypes.func
} }
const STICKY_EDGE_INSET = 20 const STICKY_EDGE_INSET = 20
@ -546,7 +549,8 @@ const DashboardTabs = () => {
reorderTabs, reorderTabs,
handleTabDragStart, handleTabDragStart,
handleTabDragEnd, handleTabDragEnd,
handleExternalTabDrop handleExternalTabDrop,
captureTabPreview
} = useNavigationTabs() } = useNavigationTabs()
const { rootRef, listRef, cloneRef, lineRef } = useActiveTabStickyScroll( const { rootRef, listRef, cloneRef, lineRef } = useActiveTabStickyScroll(
activeTabId, activeTabId,
@ -720,7 +724,8 @@ const DashboardTabs = () => {
onKeyDown: handleKeyDown, onKeyDown: handleKeyDown,
onDragOver: handleItemDragOver, onDragOver: handleItemDragOver,
onDrop: handleItemDrop, onDrop: handleItemDrop,
onClose: closeTab onClose: closeTab,
captureTabPreview
} }
return ( return (

View File

@ -1,11 +1,11 @@
import { Descriptions, Card, Flex, Divider, Skeleton } from 'antd' import { Descriptions, Card, Flex, Divider, Skeleton } from 'antd'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import ObjectProperty from './ObjectProperty' import ObjectProperty from './ObjectProperty'
import { createElement } from 'react' import { createElement, memo } from 'react'
import Thumbnail from './Thumbnail' import Thumbnail from './Thumbnail'
import { LoadingOutlined } from '@ant-design/icons' import { LoadingOutlined } from '@ant-design/icons'
const ObjectCard = ({ const ObjectCard = memo(function ObjectCard({
isSkeleton = false, isSkeleton = false,
model, model,
modelProperties, modelProperties,
@ -16,7 +16,7 @@ const ObjectCard = ({
renderActions, renderActions,
lazyLoading = false, lazyLoading = false,
cardStyle = 'borderless' cardStyle = 'borderless'
}) => { }) {
const descriptionItems = [] const descriptionItems = []
const modelIcon = createElement(model.icon, { style: { fontSize: 24 } }) const modelIcon = createElement(model.icon, { style: { fontSize: 24 } })
@ -169,7 +169,9 @@ const ObjectCard = ({
</Flex> </Flex>
</Card> </Card>
) )
} })
ObjectCard.displayName = 'ObjectCard'
ObjectCard.propTypes = { ObjectCard.propTypes = {
model: PropTypes.object.isRequired, model: PropTypes.object.isRequired,

View File

@ -14,6 +14,7 @@ import PropTypes from 'prop-types'
import classNames from 'classnames' import classNames from 'classnames'
import { ApiServerContext } from '../context/ApiServerContext' import { ApiServerContext } from '../context/ApiServerContext'
import { AuthContext } from '../context/AuthContext' import { AuthContext } from '../context/AuthContext'
import { useIsNavigationTabActive } from '../context/NavigationTabsContext'
import ObjectCard from './ObjectCard' import ObjectCard from './ObjectCard'
import ObjectKanbanColumn from './ObjectKanbanColumn' import ObjectKanbanColumn from './ObjectKanbanColumn'
import ObjectKanbanHeader from './ObjectKanbanHeader' import ObjectKanbanHeader from './ObjectKanbanHeader'
@ -287,6 +288,7 @@ const ObjectKanban = forwardRef(
subscribeToObjectTypeUpdates subscribeToObjectTypeUpdates
} = useContext(ApiServerContext) } = useContext(ApiServerContext)
const { token } = useContext(AuthContext) const { token } = useContext(AuthContext)
const isTabActive = useIsNavigationTabActive()
const columnRefs = useRef({}) const columnRefs = useRef({})
const headerTrackRef = useRef(null) const headerTrackRef = useRef(null)
const skeletonTrackRef = useRef(null) const skeletonTrackRef = useRef(null)
@ -720,7 +722,7 @@ const ObjectKanban = forwardRef(
}, [connected, token]) }, [connected, token])
useEffect(() => { useEffect(() => {
if (connected !== true || !type || !token) return if (connected !== true || !type || !token || !isTabActive) return
const unsubscribe = subscribeToAllObjectUpdates( const unsubscribe = subscribeToAllObjectUpdates(
type, type,
@ -735,10 +737,10 @@ const ObjectKanban = forwardRef(
subscribeToAllObjectUpdatesRef.current = null subscribeToAllObjectUpdatesRef.current = null
} }
} }
}, [type, connected, subscribeToAllObjectUpdates, token]) }, [type, connected, subscribeToAllObjectUpdates, token, isTabActive])
useEffect(() => { useEffect(() => {
if (connected !== true || !token) return if (connected !== true || !token || !isTabActive) return
if (subscribedTypeRef.current === type) return if (subscribedTypeRef.current === type) return
const unsubscribe = subscribeToObjectTypeUpdatesFnRef.current( const unsubscribe = subscribeToObjectTypeUpdatesFnRef.current(
@ -757,7 +759,7 @@ const ObjectKanban = forwardRef(
subscribedTypeRef.current = null subscribedTypeRef.current = null
} }
} }
}, [type, connected, token]) }, [type, connected, token, isTabActive])
useImperativeHandle( useImperativeHandle(
ref, ref,

View File

@ -1,5 +1,6 @@
import { import {
forwardRef, forwardRef,
memo,
useImperativeHandle, useImperativeHandle,
useRef, useRef,
useEffect, useEffect,
@ -45,6 +46,7 @@ import QuestionCircleIcon from '../../Icons/QuestionCircleIcon'
import { AuthContext } from '../context/AuthContext' import { AuthContext } from '../context/AuthContext'
import { ElectronContext } from '../context/ElectronContext' import { ElectronContext } from '../context/ElectronContext'
import { useActions } from '../context/ActionsContext' import { useActions } from '../context/ActionsContext'
import { useIsNavigationTabActive } from '../context/NavigationTabsContext'
import ActionsIcon from '../../Icons/ActionsIcon' import ActionsIcon from '../../Icons/ActionsIcon'
import FilterIcon from '../../Icons/FilterIcon' import FilterIcon from '../../Icons/FilterIcon'
import ScrollBox from './ScrollBox' import ScrollBox from './ScrollBox'
@ -80,6 +82,7 @@ logger.setLevel(config.logLevel)
const SCROLL_THRESHOLD = 50 const SCROLL_THRESHOLD = 50
const SKELETON_HEIGHT = 49.5 const SKELETON_HEIGHT = 49.5
const EMPTY_MASTER_FILTER = {} const EMPTY_MASTER_FILTER = {}
const EMPTY_VISIBLE_COLUMNS = {}
const getCardColSpan = (containerWidth) => { const getCardColSpan = (containerWidth) => {
if (containerWidth >= 2980) return 2 if (containerWidth >= 2980) return 2
@ -291,8 +294,9 @@ EditableRow.propTypes = {
onRegister: PropTypes.func onRegister: PropTypes.func
} }
const ObjectTable = forwardRef( const ObjectTable = memo(
( forwardRef(
(
{ {
type, type,
pageSize = 25, pageSize = 25,
@ -301,7 +305,7 @@ const ObjectTable = forwardRef(
initialPage = 1, initialPage = 1,
viewMode: viewModeProp, viewMode: viewModeProp,
cards = false, cards = false,
visibleColumns = {}, visibleColumns = EMPTY_VISIBLE_COLUMNS,
masterFilter, masterFilter,
size = 'middle', size = 'middle',
onStateChange, onStateChange,
@ -335,6 +339,7 @@ const ObjectTable = forwardRef(
const { token, userProfile } = useContext(AuthContext) const { token, userProfile } = useContext(AuthContext)
const { isElectron } = useContext(ElectronContext) const { isElectron } = useContext(ElectronContext)
const { callAction } = useActions() const { callAction } = useActions()
const isTabActive = useIsNavigationTabActive()
const resolvedMasterFilter = masterFilter ?? EMPTY_MASTER_FILTER const resolvedMasterFilter = masterFilter ?? EMPTY_MASTER_FILTER
const listViewId = objectListView?.listViewId ?? null const listViewId = objectListView?.listViewId ?? null
const listViewFilter = objectListView?.listViewFilter ?? null const listViewFilter = objectListView?.listViewFilter ?? null
@ -517,8 +522,68 @@ const ObjectTable = forwardRef(
return {} return {}
}, []) }, [])
const rowActions = const rowActions = useMemo(
model.actions?.filter((action) => action.row == true) || [] () => model.actions?.filter((action) => action.row == true) || [],
[model]
)
const renderActions = useCallback(
(objectData, actionsDisabled = false) => {
return (
<Flex gap='small' align='center' justify='center'>
{rowActions.map((action, index) => {
const denied = !hasActionPermission(userProfile, model, action.name)
var disabled = denied
if (action.disabled) {
if (typeof action.disabled === 'function') {
disabled =
denied ||
action.disabled({
...objectData,
_user: userProfile
}) ||
actionsDisabled
} else {
disabled = denied || action.disabled || actionsDisabled
}
}
return (
<Tooltip key={index} title={action.label}>
<Button
icon={
action.icon ? (
createElement(action.icon)
) : (
<QuestionCircleIcon />
)
}
disabled={disabled || objectData?.isSkeleton}
type={'text'}
size={'small'}
danger={action?.danger || false}
onClick={() => {
if (denied) return
if (
onRowAction &&
onRowAction(action, objectData) !== false
) {
return
}
callAction(
action,
{ ...objectData, _user: userProfile },
type
)
}}
/>
</Tooltip>
)
})}
</Flex>
)
},
[callAction, model, onRowAction, rowActions, type, userProfile]
)
const createSkeletonData = useCallback( const createSkeletonData = useCallback(
(pageNum) => { (pageNum) => {
@ -602,61 +667,6 @@ const ObjectTable = forwardRef(
) )
}, []) }, [])
const renderActions = (objectData, actionsDisabled = false) => {
return (
<Flex gap='small' align='center' justify='center'>
{rowActions.map((action, index) => {
const denied = !hasActionPermission(userProfile, model, action.name)
var disabled = denied
if (action.disabled) {
if (typeof action.disabled === 'function') {
disabled =
denied ||
action.disabled({
...objectData,
_user: userProfile
}) ||
actionsDisabled
} else {
disabled = denied || action.disabled || actionsDisabled
}
}
return (
<Tooltip key={index} title={action.label}>
<Button
icon={
action.icon ? (
createElement(action.icon)
) : (
<QuestionCircleIcon />
)
}
disabled={disabled || objectData?.isSkeleton}
type={'text'}
size={'small'}
danger={action?.danger || false}
onClick={() => {
if (denied) return
if (
onRowAction &&
onRowAction(action, objectData) !== false
) {
return
}
callAction(
action,
{ ...objectData, _user: userProfile },
type
)
}}
/>
</Tooltip>
)
})}
</Flex>
)
}
const fetchPage = useCallback( const fetchPage = useCallback(
async (pageNum = 1, filter = null, sorter = null) => { async (pageNum = 1, filter = null, sorter = null) => {
if (filter == null) { if (filter == null) {
@ -1254,7 +1264,7 @@ const ObjectTable = forwardRef(
// Subscribe to all object updates for this type (list/cards only) // Subscribe to all object updates for this type (list/cards only)
useEffect(() => { useEffect(() => {
if (isKanban || connected !== true || !type) return if (isKanban || connected !== true || !type || !isTabActive) return
const unsubscribe = subscribeToAllObjectUpdates( const unsubscribe = subscribeToAllObjectUpdates(
type, type,
@ -1269,10 +1279,10 @@ const ObjectTable = forwardRef(
subscribeToAllObjectUpdatesRef.current = null subscribeToAllObjectUpdatesRef.current = null
} }
} }
}, [type, connected, subscribeToAllObjectUpdates, isKanban]) }, [type, connected, subscribeToAllObjectUpdates, isKanban, isTabActive])
useEffect(() => { useEffect(() => {
if (isKanban || connected !== true) return if (isKanban || connected !== true || !isTabActive) return
if (subscribedTypeRef.current === type) return if (subscribedTypeRef.current === type) return
const unsubscribe = subscribeToObjectTypeUpdatesFnRef.current( const unsubscribe = subscribeToObjectTypeUpdatesFnRef.current(
@ -1291,7 +1301,7 @@ const ObjectTable = forwardRef(
subscribedTypeRef.current = null subscribedTypeRef.current = null
} }
} }
}, [type, connected, isKanban]) }, [type, connected, isKanban, isTabActive])
const updateData = useCallback( const updateData = useCallback(
(id, updatedData) => { (id, updatedData) => {
@ -1706,27 +1716,30 @@ const ObjectTable = forwardRef(
return () => registerPageSorter({}) return () => registerPageSorter({})
}, [effectiveSorter, registerPageSorter, registerObjectListSorter]) }, [effectiveSorter, registerPageSorter, registerObjectListSorter])
const getFilterDropdown = ({ const getFilterDropdown = useCallback(
setSelectedKeys, ({
selectedKeys, setSelectedKeys,
confirm, selectedKeys,
clearFilters, confirm,
visible, clearFilters,
propertyName, visible,
propertyLabel propertyName,
}) => ( propertyLabel
<ColumnFilterDropdown }) => (
setSelectedKeys={setSelectedKeys} <ColumnFilterDropdown
selectedKeys={selectedKeys} setSelectedKeys={setSelectedKeys}
confirm={confirm} selectedKeys={selectedKeys}
clearFilters={clearFilters} confirm={confirm}
visible={visible} clearFilters={clearFilters}
propertyName={propertyName} visible={visible}
propertyLabel={propertyLabel} propertyName={propertyName}
modelType={type} propertyLabel={propertyLabel}
filter={effectiveFilter} modelType={type}
masterFilter={resolvedMasterFilter} filter={effectiveFilter}
/> masterFilter={resolvedMasterFilter}
/>
),
[effectiveFilter, resolvedMasterFilter, type]
) )
const handleTableChange = (pagination, filters, sorter) => { const handleTableChange = (pagination, filters, sorter) => {
@ -1858,66 +1871,7 @@ const ObjectTable = forwardRef(
] ]
) )
const modelProperties = getModelProperties(type) const modelProperties = useMemo(() => getModelProperties(type), [type])
// Table columns from model properties
const columnsWithSkeleton = [
{
title:
loading || lazyLoading ? (
<div style={{ marginLeft: 7 }}>
<LoadingOutlined style={{ fontSize: 14 }} />
</div>
) : canBulkSelect ? (
<div style={{ marginLeft: 7 }}>
<Checkbox
checked={bulkSelection?.allMatching === true}
indeterminate={
!bulkSelection?.allMatching && selectedRowIds.length > 0
}
onChange={(event) => toggleSelectAll(event.target.checked)}
aria-label={`Select all ${model.labelPlural || model.label}`}
/>
</div>
) : isCards ? (
model.icon
) : null,
key: 'icon',
width: 45,
fixed: 'left',
render: (_, record) => {
const selected =
bulkSelection?.allMatching === true ||
selectedRowIdSet.has(String(record?._id))
if (!canBulkSelect || record?.isSkeleton) {
return <Flex justify='center'>{createElement(model.icon)}</Flex>
}
return (
<Flex justify='center'>
<div
className={classNames('object-table-select-cell', {
'object-table-select-cell-selected': selected
})}
>
<Checkbox
checked={selected}
onClick={(event) => event.stopPropagation()}
onChange={(event) =>
toggleRowSelection(record, event.target.checked)
}
className='object-table-row-checkbox'
aria-label={`Select ${record?.name || record?._reference || record?._id}`}
/>
<div className='object-table-row-icon'>
{createElement(model.icon)}
</div>
</div>
</Flex>
)
}
}
]
useEffect(() => { useEffect(() => {
pagesRef.current = pages pagesRef.current = pages
@ -1954,131 +1908,211 @@ const ObjectTable = forwardRef(
tableData tableData
]) ])
// Add columns in the order specified by model.columns const hasRealTableRows = tableData.some((item) => !item?.isSkeleton)
model.columns.forEach((colName) => {
const prop = modelProperties.find((p) => p.name === colName)
if (prop) {
// Check if column should be visible based on visibleColumns prop
if (
Object.keys(visibleColumns).length > 0 &&
visibleColumns[prop.name] === false
) {
return // Skip this column if it's not visible
}
var fixed = prop.columnFixed || undefined const columnsWithSkeleton = useMemo(() => {
var width = 200 const columns = [
{
switch (prop.type) { title:
case 'text': loading || lazyLoading ? (
width = 200 <div style={{ marginLeft: 7 }}>
break <LoadingOutlined style={{ fontSize: 14 }} />
case 'number': </div>
width = 100 ) : canBulkSelect ? (
break <div style={{ marginLeft: 7 }}>
case 'dateTime': <Checkbox
width = 200 checked={bulkSelection?.allMatching === true}
break indeterminate={
case 'state': !bulkSelection?.allMatching && selectedRowIds.length > 0
width = 200 }
break onChange={(event) => toggleSelectAll(event.target.checked)}
case 'id': aria-label={`Select all ${model.labelPlural || model.label}`}
width = 180
break
default:
break
}
// Check if this property should be filterable based on model.filters
const isFilterable = model.filters && model.filters.includes(prop.name)
// Check if this property should be sortable based on model.sorters
const isSortable = model.sorters && model.sorters.includes(prop.name)
const columnConfig = {
sorter: isSortable ? { multiple: 1 } : undefined,
sortOrder:
tableSorter?.field === prop.name ? tableSorter.order : null,
title: prop.label,
width: prop.columnWidth || width,
fixed: isMobile ? undefined : fixed,
key: prop.name,
filterIcon: () => {
return (
<Tooltip title='Filter' listenParents={1}>
<FilterIcon />
</Tooltip>
)
},
render: (text, record) => {
if (record?.isSkeleton) {
return (
<Skeleton.Input
active
size='small'
style={{ width: '100%', height: 24.5 }}
/> />
) </div>
) : isCards ? (
model.icon
) : null,
key: 'icon',
width: 45,
fixed: 'left',
render: (_, record) => {
const selected =
bulkSelection?.allMatching === true ||
selectedRowIdSet.has(String(record?._id))
if (!canBulkSelect || record?.isSkeleton) {
return <Flex justify='center'>{createElement(model.icon)}</Flex>
} }
return ( return (
<ObjectProperty <Flex justify='center'>
{...prop} <div
longId={false} className={classNames('object-table-select-cell', {
inTable={true} 'object-table-select-cell-selected': selected
objectData={record} })}
isEditing={isEditing} >
/> <Checkbox
checked={selected}
onClick={(event) => event.stopPropagation()}
onChange={(event) =>
toggleRowSelection(record, event.target.checked)
}
className='object-table-row-checkbox'
aria-label={`Select ${record?.name || record?._reference || record?._id}`}
/>
<div className='object-table-row-icon'>
{createElement(model.icon)}
</div>
</div>
</Flex>
) )
} }
} }
]
if ( model.columns.forEach((colName) => {
isFilterable && const prop = modelProperties.find((p) => p.name === colName)
!Object.keys(resolvedMasterFilter).includes(prop.name) if (prop) {
) { if (
columnConfig.filterDropdown = ({ Object.keys(visibleColumns).length > 0 &&
setSelectedKeys, visibleColumns[prop.name] === false
selectedKeys, ) {
confirm, return
clearFilters, }
visible
}) => var fixed = prop.columnFixed || undefined
getFilterDropdown({ var width = 200
switch (prop.type) {
case 'text':
width = 200
break
case 'number':
width = 100
break
case 'dateTime':
width = 200
break
case 'state':
width = 200
break
case 'id':
width = 180
break
default:
break
}
const isFilterable = model.filters && model.filters.includes(prop.name)
const isSortable = model.sorters && model.sorters.includes(prop.name)
const columnConfig = {
sorter: isSortable ? { multiple: 1 } : undefined,
sortOrder:
tableSorter?.field === prop.name ? tableSorter.order : null,
title: prop.label,
width: prop.columnWidth || width,
fixed: isMobile ? undefined : fixed,
key: prop.name,
filterIcon: () => {
return (
<Tooltip title='Filter' listenParents={1}>
<FilterIcon />
</Tooltip>
)
},
render: (text, record) => {
if (record?.isSkeleton) {
return (
<Skeleton.Input
active
size='small'
style={{ width: '100%', height: 24.5 }}
/>
)
}
return (
<ObjectProperty
{...prop}
longId={false}
inTable={true}
objectData={record}
isEditing={isEditing}
/>
)
}
}
if (
isFilterable &&
!Object.keys(resolvedMasterFilter).includes(prop.name)
) {
columnConfig.filterDropdown = ({
setSelectedKeys, setSelectedKeys,
selectedKeys, selectedKeys,
confirm, confirm,
clearFilters, clearFilters,
visible, visible
propertyName: prop.name, }) =>
propertyLabel: prop.label getFilterDropdown({
}) setSelectedKeys,
columnConfig.filteredValue = fromFilterExpression( selectedKeys,
effectiveFilter[prop.name] confirm,
) clearFilters,
} visible,
propertyName: prop.name,
propertyLabel: prop.label
})
columnConfig.filteredValue = fromFilterExpression(
effectiveFilter[prop.name]
)
}
columnsWithSkeleton.push(columnConfig) columns.push(columnConfig)
}
})
if (
showActions &&
rowActions.length > 0 &&
tableData.some((item) => !item?.isSkeleton)
) {
columnsWithSkeleton.push({
title: (
<Flex gap='small' align='center' justify='center'>
<ActionsIcon />
</Flex>
),
key: 'actions',
fixed: 'right',
width: 20 + rowActions.length * 30, // Adjust width based on number of actions
render: (record) => {
return renderActions(record)
} }
}) })
}
if (showActions && rowActions.length > 0 && hasRealTableRows) {
columns.push({
title: (
<Flex gap='small' align='center' justify='center'>
<ActionsIcon />
</Flex>
),
key: 'actions',
fixed: 'right',
width: 20 + rowActions.length * 30,
render: (record) => {
return renderActions(record)
}
})
}
return columns
}, [
bulkSelection,
canBulkSelect,
effectiveFilter,
getFilterDropdown,
hasRealTableRows,
isCards,
isEditing,
isMobile,
lazyLoading,
loading,
model,
modelProperties,
renderActions,
resolvedMasterFilter,
rowActions,
selectedRowIdSet,
selectedRowIds,
showActions,
tableSorter,
toggleRowSelection,
toggleSelectAll,
visibleColumns
])
// Card view rendering // Card view rendering
const [cardColSpan, setCardColSpan] = useState(24) const [cardColSpan, setCardColSpan] = useState(24)
@ -2465,6 +2499,7 @@ const ObjectTable = forwardRef(
</ObjectTableFilterContext.Provider> </ObjectTableFilterContext.Provider>
) )
} }
)
) )
ObjectTable.displayName = 'ObjectTable' ObjectTable.displayName = 'ObjectTable'

View File

@ -4,6 +4,7 @@ import {
useCallback, useCallback,
useContext, useContext,
useEffect, useEffect,
useMemo,
useRef, useRef,
useState useState
} from 'react' } from 'react'
@ -26,6 +27,8 @@ const ActionsContext = createContext()
const ActionsProvider = ({ children }) => { const ActionsProvider = ({ children }) => {
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const locationRef = useRef(location)
locationRef.current = location
const { userProfile } = useContext(AuthContext) const { userProfile } = useContext(AuthContext)
const [currentObject, setCurrentObject] = useState(null) const [currentObject, setCurrentObject] = useState(null)
const [currentObjectType, setCurrentObjectType] = useState(null) const [currentObjectType, setCurrentObjectType] = useState(null)
@ -38,13 +41,14 @@ const ActionsProvider = ({ children }) => {
const actionObjectType = searchParams.get('actionObjectType') const actionObjectType = searchParams.get('actionObjectType')
const clearAction = useCallback(() => { const clearAction = useCallback(() => {
const nextUrl = stripModalActionParams(location.pathname, location.search) const loc = locationRef.current
if (nextUrl !== location.pathname + location.search) { const nextUrl = stripModalActionParams(loc.pathname, loc.search)
if (nextUrl !== loc.pathname + loc.search) {
navigate(nextUrl, { replace: true }) navigate(nextUrl, { replace: true })
} }
setModalAction(null) setModalAction(null)
lastHandledAction.current = null lastHandledAction.current = null
}, [location.pathname, location.search, navigate]) }, [navigate])
const handleModalOk = useCallback(() => { const handleModalOk = useCallback(() => {
onModalOk?.() onModalOk?.()
@ -56,16 +60,17 @@ const ActionsProvider = ({ children }) => {
setCurrentObject(objectData) setCurrentObject(objectData)
setCurrentObjectType(objectType) setCurrentObjectType(objectType)
const model = getModelByName(objectType) const model = getModelByName(objectType)
const loc = locationRef.current
const url = buildActionUrl( const url = buildActionUrl(
model, model,
action, action,
objectData._id, objectData._id,
location.pathname, loc.pathname,
location.search loc.search
) )
navigate(url) navigate(url)
}, },
[location.pathname, location.search, navigate] [navigate]
) )
useEffect(() => { useEffect(() => {
@ -187,18 +192,21 @@ const ActionsProvider = ({ children }) => {
? {} ? {}
: modalObjectData : modalObjectData
const value = useMemo(
() => ({
currentObject,
currentObjectType,
setCurrentObject,
setCurrentObjectType,
callAction,
clearAction,
setOnModalOk
}),
[callAction, clearAction, currentObject, currentObjectType]
)
return ( return (
<ActionsContext.Provider <ActionsContext.Provider value={value}>
value={{
currentObject,
currentObjectType,
setCurrentObject,
setCurrentObjectType,
callAction,
clearAction,
setOnModalOk
}}
>
<Modal <Modal
open={resolvedModalAction != null} open={resolvedModalAction != null}
destroyOnHidden={true} destroyOnHidden={true}

View File

@ -5,7 +5,8 @@ import {
useState, useState,
useContext, useContext,
useRef, useRef,
useCallback useCallback,
useMemo
} from 'react' } from 'react'
import io from 'socket.io-client' import io from 'socket.io-client'
import { message, Modal, Space, Button, Typography, Flex } from 'antd' import { message, Modal, Space, Button, Typography, Flex } from 'antd'
@ -133,10 +134,244 @@ const getObjectTypeSubscriptionArgs = (filterOrCallback, callback) => {
const getObjectEndpoint = (type) => const getObjectEndpoint = (type) =>
getModelByName(type)?.endpoint || `${type.toLowerCase()}s` getModelByName(type)?.endpoint || `${type.toLowerCase()}s`
const formatFileName = (name) => {
if (!name || typeof name !== 'string') {
return ''
}
const cleaned = name.replace(/[^a-zA-Z0-9.\-_\s]/g, '')
const normalized = cleaned.trim().replace(/\s+/g, '_')
return normalized.slice(0, 255)
}
const API_METHOD_KEYS = [
'getUserSettings',
'updateUserSettings',
'setObjectActivity',
'clearObjectActivity',
'fetchObjectActivities',
'updateObject',
'updateMultipleObjects',
'createObject',
'getObjectFunction',
'sendObjectFunction',
'deleteObject',
'deleteObjects',
'subscribeToObjectUpdates',
'subscribeToAllObjectUpdates',
'subscribeToObjectEvent',
'subscribeToObjectTypeUpdates',
'subscribeToObjectActivity',
'subscribeToModelStats',
'fetchObject',
'fetchObjects',
'getObjectNeighbors',
'fetchObjectsByProperty',
'searchObjects',
'fetchSpotlightData',
'getModelStats',
'getModelPropertyValues',
'getModelHistory',
'showError',
'fetchFileContent',
'fetchFileThumbnail',
'exportToExcel',
'exportToCsv',
'fetchTemplatePreview',
'fetchTemplateIntellisense',
'fetchTemplateFormat',
'fetchTemplatePDF',
'fetchTemplateDownload',
'fetchNotes',
'downloadTemplate',
'downloadTemplatePDF',
'fetchHostOTP',
'sendObjectAction',
'uploadFile',
'flushFile',
'formatFileName',
'createUserNotifier',
'deleteUserNotifier',
'fetchUserNotifiersForObject',
'fetchAllUserNotifiersForObject',
'toggleUserNotifier',
'editUserNotifier',
'fetchObjectViews',
'createObjectView',
'updateObjectView',
'deleteObjectView',
'fetchNotificationsApi',
'markNotificationAsReadApi',
'markAllNotificationsAsReadApi',
'deleteNotificationApi',
'deleteAllNotificationsApi',
'registerNotificationListener',
'unregisterNotificationListener',
'getMarketplaceAuthUrl',
'refreshMarketplaceAuth',
'completeAppLaunchSession',
'getAppLaunchSession',
'fetchAppUpdateBranches',
'fetchAppUpdateCurrent',
'fetchWsServerVersion',
'fetchApiServerVersion'
]
const createStableApiMethods = (methodsRef) => {
const bound = {}
for (const key of API_METHOD_KEYS) {
bound[key] = (...args) => methodsRef.current[key]?.(...args)
}
return bound
}
const LaunchSessionCompleter = ({ completeAppLaunchSession }) => {
const location = useLocation()
const navigate = useNavigate()
const { token, authenticated } = useContext(AuthContext)
const completedLaunchSessionsRef = useRef(new Set())
useEffect(() => {
const launchSession = new URLSearchParams(location.search).get(
'launchSession'
)
if (
authenticated !== true ||
!token ||
!launchSession ||
completedLaunchSessionsRef.current.has(launchSession)
) {
return
}
completedLaunchSessionsRef.current.add(launchSession)
completeAppLaunchSession(launchSession)
.then(() => {
const searchParams = new URLSearchParams(location.search)
searchParams.delete('launchSession')
const newSearch = searchParams.toString()
const newPath = location.pathname + (newSearch ? `?${newSearch}` : '')
navigate(newPath, { replace: true })
})
.catch((err) => {
logger.error('Failed to complete app launch session:', err)
completedLaunchSessionsRef.current.delete(launchSession)
})
}, [
token,
authenticated,
completeAppLaunchSession,
location.search,
location.pathname,
navigate
])
return null
}
LaunchSessionCompleter.propTypes = {
completeAppLaunchSession: PropTypes.func.isRequired
}
const ConnectionIssueModal = ({
open,
delayMs,
cycle,
isReconnecting,
onReconnect
}) => {
const [reconnectRemainingMs, setReconnectRemainingMs] = useState(delayMs)
const [reconnectProgress, setReconnectProgress] = useState(0)
useEffect(() => {
if (!open || isReconnecting) return undefined
const startedAt = Date.now()
setReconnectRemainingMs(delayMs)
setReconnectProgress(0)
const intervalId = setInterval(() => {
const elapsed = Date.now() - startedAt
const remaining = Math.max(0, delayMs - elapsed)
setReconnectRemainingMs(remaining)
setReconnectProgress(Math.min(100, (elapsed / delayMs) * 100))
}, 100)
return () => clearInterval(intervalId)
}, [open, isReconnecting, delayMs, cycle])
const reconnectSecondsRemaining = Math.max(
1,
Math.ceil(reconnectRemainingMs / 1000)
)
return (
<Modal
title={
!isReconnecting ? (
<Space size={'middle'}>
<ExclamationOctagonIcon />
Connection Lost
</Space>
) : (
false
)
}
open={open}
style={{ maxWidth: !isReconnecting ? 480 : 260 }}
zIndex={3000}
closable={false}
height={isReconnecting ? 20 : undefined}
centered
className={isReconnecting ? 'loading-modal' : undefined}
maskClosable={false}
getContainer={() => document.body}
footer={
!isReconnecting
? [
<Button key='reconnect' loading={isReconnecting} onClick={onReconnect}>
Reconnect
</Button>
]
: false
}
>
{!isReconnecting ? (
<Flex vertical gap='middle'>
<Text>
{`Lost connection to the API server. Reconnecting in ${reconnectSecondsRemaining} second${
reconnectSecondsRemaining === 1 ? '' : 's'
}...`}
</Text>
<ProgressDisplay
percent={100 - reconnectProgress}
showInfo={false}
status={'exception'}
/>
</Flex>
) : (
<Space size={'middle'}>
<LoadingOutlined />
<Text style={{ margin: 0 }}>Reconnecting, please wait...</Text>
</Space>
)}
</Modal>
)
}
ConnectionIssueModal.propTypes = {
open: PropTypes.bool,
delayMs: PropTypes.number,
cycle: PropTypes.number,
isReconnecting: PropTypes.bool,
onReconnect: PropTypes.func
}
const ApiServerContext = createContext() const ApiServerContext = createContext()
const ApiServerProvider = ({ children }) => { const ApiServerProvider = ({ children }) => {
const location = useLocation()
const navigate = useNavigate() const navigate = useNavigate()
const { const {
token, token,
@ -162,16 +397,12 @@ const ApiServerProvider = ({ children }) => {
const subscribedActivityCallbacksRef = useRef(new Map()) const subscribedActivityCallbacksRef = useRef(new Map())
const subscribedActivityServerSubscriptionsRef = useRef(new Set()) const subscribedActivityServerSubscriptionsRef = useRef(new Set())
const notificationListenersRef = useRef(new Set()) const notificationListenersRef = useRef(new Set())
const completedLaunchSessionsRef = useRef(new Set())
const [connectionIssue, setConnectionIssue] = useState(false) const [connectionIssue, setConnectionIssue] = useState(false)
const [reconnectProgress, setReconnectProgress] = useState(0) const [reconnectDelayMs, setReconnectDelayMs] = useState(RECONNECT_DELAYS_MS[0])
const [reconnectRemainingMs, setReconnectRemainingMs] = useState( const [reconnectCycle, setReconnectCycle] = useState(0)
RECONNECT_DELAYS_MS[0]
)
const [isReconnecting, setIsReconnecting] = useState(false) const [isReconnecting, setIsReconnecting] = useState(false)
const reconnectAttemptRef = useRef(0) const reconnectAttemptRef = useRef(0)
const reconnectTimerRef = useRef(null) const reconnectTimerRef = useRef(null)
const reconnectProgressTimerRef = useRef(null)
const reconnectScheduledRef = useRef(false) const reconnectScheduledRef = useRef(false)
const isReconnectingRef = useRef(false) const isReconnectingRef = useRef(false)
const hasConnectedOnceRef = useRef(false) const hasConnectedOnceRef = useRef(false)
@ -349,10 +580,6 @@ const ApiServerProvider = ({ children }) => {
clearTimeout(reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current)
reconnectTimerRef.current = null reconnectTimerRef.current = null
} }
if (reconnectProgressTimerRef.current) {
clearInterval(reconnectProgressTimerRef.current)
reconnectProgressTimerRef.current = null
}
}, []) }, [])
const resetReconnectState = useCallback(() => { const resetReconnectState = useCallback(() => {
@ -361,25 +588,16 @@ const ApiServerProvider = ({ children }) => {
reconnectScheduledRef.current = false reconnectScheduledRef.current = false
isReconnectingRef.current = false isReconnectingRef.current = false
setIsReconnecting(false) setIsReconnecting(false)
setReconnectProgress(0) setReconnectDelayMs(RECONNECT_DELAYS_MS[0])
setReconnectRemainingMs(RECONNECT_DELAYS_MS[0])
}, [clearReconnectTimers]) }, [clearReconnectTimers])
const startReconnectCountdown = useCallback( const startReconnectCountdown = useCallback(
(delayMs) => { (delayMs) => {
clearReconnectTimers() clearReconnectTimers()
const startedAt = Date.now() setReconnectDelayMs(delayMs)
setReconnectRemainingMs(delayMs) setReconnectCycle((current) => current + 1)
setReconnectProgress(0)
setIsReconnecting(false) setIsReconnecting(false)
reconnectProgressTimerRef.current = setInterval(() => {
const elapsed = Date.now() - startedAt
const remaining = Math.max(0, delayMs - elapsed)
setReconnectRemainingMs(remaining)
setReconnectProgress(Math.min(100, (elapsed / delayMs) * 100))
}, 100)
reconnectTimerRef.current = setTimeout(() => { reconnectTimerRef.current = setTimeout(() => {
attemptReconnectRef.current?.() attemptReconnectRef.current?.()
}, delayMs) }, delayMs)
@ -429,7 +647,6 @@ const ApiServerProvider = ({ children }) => {
isReconnectingRef.current = true isReconnectingRef.current = true
reconnectAttemptRef.current += 1 reconnectAttemptRef.current += 1
setIsReconnecting(true) setIsReconnecting(true)
setReconnectProgress(100)
setConnecting(true) setConnecting(true)
setConnectionIssue(true) setConnectionIssue(true)
@ -2769,43 +2986,6 @@ const ApiServerProvider = ({ children }) => {
[token] [token]
) )
useEffect(() => {
const launchSession = new URLSearchParams(location.search).get(
'launchSession'
)
if (
authenticated !== true ||
!token ||
!launchSession ||
completedLaunchSessionsRef.current.has(launchSession)
) {
return
}
completedLaunchSessionsRef.current.add(launchSession)
completeAppLaunchSession(launchSession)
.then(() => {
const searchParams = new URLSearchParams(location.search)
searchParams.delete('launchSession')
const newSearch = searchParams.toString()
const newPath = location.pathname + (newSearch ? `?${newSearch}` : '')
navigate(newPath, { replace: true })
})
.catch((err) => {
logger.error('Failed to complete app launch session:', err)
completedLaunchSessionsRef.current.delete(launchSession)
})
}, [
token,
authenticated,
completeAppLaunchSession,
location.search,
location.pathname,
navigate
])
const getAppLaunchSession = useCallback(async (launchSession) => { const getAppLaunchSession = useCallback(async (launchSession) => {
const response = await axios.get( const response = await axios.get(
`${config.backendUrl}/applaunch/${launchSession}`, `${config.backendUrl}/applaunch/${launchSession}`,
@ -2924,167 +3104,120 @@ const ApiServerProvider = ({ children }) => {
} }
} }
const reconnectSecondsRemaining = Math.max( const apiMethodsRef = useRef({})
1, apiMethodsRef.current = {
Math.ceil(reconnectRemainingMs / 1000) getUserSettings,
) updateUserSettings,
setObjectActivity,
// Sanitize a string so it is safe to use as a filename on most file systems clearObjectActivity,
const formatFileName = (name) => { fetchObjectActivities,
if (!name || typeof name !== 'string') { updateObject,
return '' updateMultipleObjects,
} createObject,
getObjectFunction,
// Remove characters that are problematic on most common file systems sendObjectFunction,
const cleaned = name.replace(/[^a-zA-Z0-9.\-_\s]/g, '') deleteObject,
deleteObjects,
// Normalize whitespace to single underscores subscribeToObjectUpdates,
const normalized = cleaned.trim().replace(/\s+/g, '_') subscribeToAllObjectUpdates,
subscribeToObjectEvent,
// Most file systems limit filenames to 255 characters subscribeToObjectTypeUpdates,
return normalized.slice(0, 255) subscribeToObjectActivity,
subscribeToModelStats,
fetchObject,
fetchObjects,
getObjectNeighbors,
fetchObjectsByProperty,
searchObjects,
fetchSpotlightData,
getModelStats,
getModelPropertyValues,
getModelHistory,
showError,
fetchFileContent,
fetchFileThumbnail,
exportToExcel,
exportToCsv,
fetchTemplatePreview,
fetchTemplateIntellisense,
fetchTemplateFormat,
fetchTemplatePDF,
fetchTemplateDownload,
fetchNotes,
downloadTemplate,
downloadTemplatePDF,
fetchHostOTP,
sendObjectAction,
uploadFile,
flushFile,
formatFileName,
createUserNotifier,
deleteUserNotifier,
fetchUserNotifiersForObject,
fetchAllUserNotifiersForObject,
toggleUserNotifier,
editUserNotifier,
fetchObjectViews,
createObjectView,
updateObjectView,
deleteObjectView,
fetchNotificationsApi,
markNotificationAsReadApi,
markAllNotificationsAsReadApi,
deleteNotificationApi,
deleteAllNotificationsApi,
registerNotificationListener,
unregisterNotificationListener,
getMarketplaceAuthUrl,
refreshMarketplaceAuth,
completeAppLaunchSession,
getAppLaunchSession,
fetchAppUpdateBranches,
fetchAppUpdateCurrent,
fetchWsServerVersion,
fetchApiServerVersion
} }
const stableApiRef = useRef(null)
if (stableApiRef.current == null) {
stableApiRef.current = createStableApiMethods(apiMethodsRef)
}
const value = useMemo(
() => ({
...stableApiRef.current,
apiServer: socketRef.current,
error,
connecting,
connected,
userSettings,
userSettingsLoaded,
fetchLoading
}),
[
connecting,
connected,
error,
fetchLoading,
userSettings,
userSettingsLoaded
]
)
return ( return (
<ApiServerContext.Provider <ApiServerContext.Provider value={value}>
value={{
apiServer: socketRef.current,
error,
connecting,
connected,
userSettings,
userSettingsLoaded,
getUserSettings,
updateUserSettings,
setObjectActivity,
clearObjectActivity,
fetchObjectActivities,
updateObject,
updateMultipleObjects,
createObject,
getObjectFunction,
sendObjectFunction,
deleteObject,
deleteObjects,
subscribeToObjectUpdates,
subscribeToAllObjectUpdates,
subscribeToObjectEvent,
subscribeToObjectTypeUpdates,
subscribeToObjectActivity,
subscribeToModelStats,
fetchObject,
fetchObjects,
getObjectNeighbors,
fetchObjectsByProperty,
searchObjects,
fetchSpotlightData,
getModelStats,
getModelPropertyValues,
getModelHistory,
fetchLoading,
showError,
fetchFileContent,
fetchFileThumbnail,
exportToExcel,
exportToCsv,
fetchTemplatePreview,
fetchTemplateIntellisense,
fetchTemplateFormat,
fetchTemplatePDF,
fetchTemplateDownload,
fetchNotes,
downloadTemplate,
downloadTemplatePDF,
fetchHostOTP,
sendObjectAction,
uploadFile,
flushFile,
formatFileName,
createUserNotifier,
deleteUserNotifier,
fetchUserNotifiersForObject,
fetchAllUserNotifiersForObject,
toggleUserNotifier,
editUserNotifier,
fetchObjectViews,
createObjectView,
updateObjectView,
deleteObjectView,
fetchNotificationsApi,
markNotificationAsReadApi,
markAllNotificationsAsReadApi,
deleteNotificationApi,
deleteAllNotificationsApi,
registerNotificationListener,
unregisterNotificationListener,
getMarketplaceAuthUrl,
refreshMarketplaceAuth,
completeAppLaunchSession,
getAppLaunchSession,
fetchAppUpdateBranches,
fetchAppUpdateCurrent,
fetchWsServerVersion,
fetchApiServerVersion
}}
>
{contextHolder} {contextHolder}
<LaunchSessionCompleter
completeAppLaunchSession={completeAppLaunchSession}
/>
{children} {children}
<Modal <ConnectionIssueModal
title={
!isReconnecting ? (
<Space size={'middle'}>
<ExclamationOctagonIcon />
Connection Lost
</Space>
) : (
false
)
}
open={Boolean(token) && authenticated == true && connectionIssue} open={Boolean(token) && authenticated == true && connectionIssue}
style={{ maxWidth: !isReconnecting ? 480 : 260 }} delayMs={reconnectDelayMs}
zIndex={3000} cycle={reconnectCycle}
closable={false} isReconnecting={isReconnecting}
height={isReconnecting ? 20 : undefined} onReconnect={attemptReconnect}
centered />
className={isReconnecting ? 'loading-modal' : undefined}
maskClosable={false}
getContainer={() => document.body}
footer={
!isReconnecting
? [
<Button
key='reconnect'
loading={isReconnecting}
onClick={() => attemptReconnect()}
>
Reconnect
</Button>
]
: false
}
>
{!isReconnecting ? (
<Flex vertical gap='middle'>
<Text>
{isReconnecting
? 'Reconnecting to the API server...'
: `Lost connection to the API server. Reconnecting in ${reconnectSecondsRemaining} second${
reconnectSecondsRemaining === 1 ? '' : 's'
}...`}
</Text>
<ProgressDisplay
percent={isReconnecting ? 0 : 100 - reconnectProgress}
showInfo={false}
status={'exception'}
/>
</Flex>
) : (
<Space size={'middle'}>
<LoadingOutlined />
<Text style={{ margin: 0 }}>Reconnecting, please wait...</Text>
</Space>
)}
</Modal>
<Modal <Modal
title={ title={
<Space size={'middle'}> <Space size={'middle'}>

View File

@ -5,7 +5,8 @@ import {
useCallback, useCallback,
useEffect, useEffect,
useContext, useContext,
useRef useRef,
useMemo
} from 'react' } from 'react'
import axios from 'axios' import axios from 'axios'
import { import {
@ -112,6 +113,8 @@ const AuthProvider = ({ children }) => {
} = useContext(ElectronContext) } = useContext(ElectronContext)
const location = useLocation() const location = useLocation()
const navigate = useNavigate() const navigate = useNavigate()
const locationRef = useRef(location)
locationRef.current = location
const sessionRef = useRef({ const sessionRef = useRef({
token, token,
expiresAt, expiresAt,
@ -432,9 +435,11 @@ const AuthProvider = ({ children }) => {
// Login using query parameters // Login using query parameters
const loginWithSSO = useCallback( const loginWithSSO = useCallback(
(redirectUri = location.pathname + location.search) => { (redirectUri) => {
const loc = locationRef.current
const nextRedirectUri = redirectUri || loc.pathname + loc.search
messageApi.info('Logging in with tombutcher.work') messageApi.info('Logging in with tombutcher.work')
const loginUrl = `${config.backendUrl}/auth/${redirectType}/login?redirect_uri=${encodeURIComponent(redirectUri)}` const loginUrl = `${config.backendUrl}/auth/${redirectType}/login?redirect_uri=${encodeURIComponent(nextRedirectUri)}`
if (isElectron) { if (isElectron) {
logger.debug('Opening external url...') logger.debug('Opening external url...')
openExternalUrl(loginUrl) openExternalUrl(loginUrl)
@ -444,14 +449,7 @@ const AuthProvider = ({ children }) => {
window.location.href = loginUrl window.location.href = loginUrl
} }
}, },
[ [redirectType, messageApi, openExternalUrl, isElectron]
redirectType,
messageApi,
openExternalUrl,
isElectron,
location.search,
location.pathname
]
) )
const getLoginToken = useCallback( const getLoginToken = useCallback(
@ -461,7 +459,6 @@ const AuthProvider = ({ children }) => {
setShowSessionExpiredModal(false) setShowSessionExpiredModal(false)
setAuthError(null) setAuthError(null)
try { try {
// Make a call to your backend to check auth status
const response = await axios.get( const response = await axios.get(
`${config.backendUrl}/auth/${redirectType}/token?code=${code}` `${config.backendUrl}/auth/${redirectType}/token?code=${code}`
) )
@ -480,7 +477,6 @@ const AuthProvider = ({ children }) => {
setAuthenticated(true) setAuthenticated(true)
setShowUnauthorizedModal(false) setShowUnauthorizedModal(false)
// Persist session (cookies on web, electron storage on desktop)
const persisted = await persistSession({ const persisted = await persistSession({
token: nextToken, token: nextToken,
expiresAt: nextExpiresAt, expiresAt: nextExpiresAt,
@ -492,10 +488,11 @@ const AuthProvider = ({ children }) => {
) )
} }
const searchParams = new URLSearchParams(location.search) const loc = locationRef.current
const searchParams = new URLSearchParams(loc.search)
searchParams.delete('authCode') searchParams.delete('authCode')
const newSearch = searchParams.toString() const newSearch = searchParams.toString()
const newPath = location.pathname + (newSearch ? `?${newSearch}` : '') const newPath = loc.pathname + (newSearch ? `?${newSearch}` : '')
navigate(newPath, { replace: true }) navigate(newPath, { replace: true })
} else { } else {
setAuthenticated(false) setAuthenticated(false)
@ -520,14 +517,7 @@ const AuthProvider = ({ children }) => {
setRetreivedTokenFromCookies(true) setRetreivedTokenFromCookies(true)
} }
}, },
[ [navigate, messageApi, persistSession, redirectType]
navigate,
location.search,
location.pathname,
messageApi,
persistSession,
redirectType
]
) )
// Function to check if the user is logged in // Function to check if the user is logged in
@ -805,25 +795,39 @@ const AuthProvider = ({ children }) => {
retreivedTokenFromCookies retreivedTokenFromCookies
]) ])
const value = useMemo(
() => ({
authenticated,
authInitialized: retreivedTokenFromCookies,
setUnauthenticated,
loginWithSSO,
getLoginToken,
token,
loading,
userProfile,
setUserProfile,
profileImageUrl,
logout
}),
[
authenticated,
getLoginToken,
loading,
loginWithSSO,
logout,
profileImageUrl,
retreivedTokenFromCookies,
setUnauthenticated,
token,
userProfile
]
)
return ( return (
<> <>
{contextHolder} {contextHolder}
{notificationContextHolder} {notificationContextHolder}
<AuthContext.Provider <AuthContext.Provider value={value}>
value={{
authenticated,
authInitialized: retreivedTokenFromCookies,
setUnauthenticated,
loginWithSSO,
getLoginToken,
token,
loading,
userProfile,
setUserProfile,
profileImageUrl,
logout
}}
>
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
<Modal <Modal

View File

@ -3,6 +3,7 @@ import {
useCallback, useCallback,
useContext, useContext,
useEffect, useEffect,
useMemo,
useRef, useRef,
useState useState
} from 'react' } from 'react'
@ -28,14 +29,17 @@ export const DashboardObjectToolsProvider = ({ children }) => {
setCurrentObjectToolsState(null) setCurrentObjectToolsState(null)
}, []) }, [])
const value = useMemo(
() => ({
currentObjectTools,
setCurrentObjectTools,
clearCurrentObjectTools
}),
[clearCurrentObjectTools, currentObjectTools, setCurrentObjectTools]
)
return ( return (
<DashboardObjectToolsContext.Provider <DashboardObjectToolsContext.Provider value={value}>
value={{
currentObjectTools,
setCurrentObjectTools,
clearCurrentObjectTools
}}
>
{children} {children}
</DashboardObjectToolsContext.Provider> </DashboardObjectToolsContext.Provider>
) )

View File

@ -1,4 +1,4 @@
import { createContext, useContext } from 'react' import { createContext, useCallback, useContext, useMemo } from 'react'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { message } from 'antd' import { message } from 'antd'
import { isElectrobunDesktop } from '../../../electrobun-bridge' import { isElectrobunDesktop } from '../../../electrobun-bridge'
@ -12,36 +12,52 @@ const MESSAGE_TOP = isElectrobunDesktop() ? 48 : undefined
export const MessageProvider = ({ children }) => { export const MessageProvider = ({ children }) => {
const [msgApi, contextHolder] = message.useMessage({ top: MESSAGE_TOP }) const [msgApi, contextHolder] = message.useMessage({ top: MESSAGE_TOP })
const showMessage = (type, content, options = {}) => { const showMessage = useCallback(
return msgApi.open({ (type, content, options = {}) => {
type, return msgApi.open({
content, type,
...options content,
}) ...options
} })
},
[msgApi]
)
const showSuccess = (content, options = {}) => const showSuccess = useCallback(
showMessage('success', content, options) (content, options = {}) => showMessage('success', content, options),
const showInfo = (content, options = {}) => [showMessage]
showMessage('info', content, options) )
const showWarning = (content, options = {}) => const showInfo = useCallback(
showMessage('warning', content, options) (content, options = {}) => showMessage('info', content, options),
const showError = (content, options = {}) => [showMessage]
showMessage('error', content, options) )
const showLoading = (content, options = {}) => const showWarning = useCallback(
showMessage('loading', content, options) (content, options = {}) => showMessage('warning', content, options),
[showMessage]
)
const showError = useCallback(
(content, options = {}) => showMessage('error', content, options),
[showMessage]
)
const showLoading = useCallback(
(content, options = {}) => showMessage('loading', content, options),
[showMessage]
)
const value = useMemo(
() => ({
msgApi,
showSuccess,
showInfo,
showWarning,
showError,
showLoading
}),
[msgApi, showError, showInfo, showLoading, showSuccess, showWarning]
)
return ( return (
<MessageContext.Provider <MessageContext.Provider value={value}>
value={{
msgApi,
showSuccess,
showInfo,
showWarning,
showError,
showLoading
}}
>
{contextHolder} {contextHolder}
{children} {children}
</MessageContext.Provider> </MessageContext.Provider>

View File

@ -183,7 +183,6 @@ export const NavigationTabsProvider = ({ children }) => {
const tabsRef = useRef(tabs) const tabsRef = useRef(tabs)
const activeTabIdRef = useRef(activeTabId) const activeTabIdRef = useRef(activeTabId)
const locationRef = useRef(location) const locationRef = useRef(location)
const previousActiveTabIdRef = useRef(null)
const isRestoringRef = useRef(null) const isRestoringRef = useRef(null)
const tabPaneElsRef = useRef(new Map()) const tabPaneElsRef = useRef(new Map())
const captureQueueRef = useRef(Promise.resolve()) const captureQueueRef = useRef(Promise.resolve())
@ -471,16 +470,6 @@ export const NavigationTabsProvider = ({ children }) => {
return next return next
}, []) }, [])
useEffect(() => {
const previousId = previousActiveTabIdRef.current
previousActiveTabIdRef.current = activeTabId
if (!hydrated || !previousId || !activeTabId || previousId === activeTabId) {
return
}
if (!tabsRef.current.some((tab) => tab.id === previousId)) return
void captureTabPreview(previousId)
}, [activeTabId, captureTabPreview, hydrated])
const setTabPage = useCallback( const setTabPage = useCallback(
({ title, modelName, iconKey, location: pageLocation } = {}) => { ({ title, modelName, iconKey, location: pageLocation } = {}) => {
const activeId = activeTabIdRef.current const activeId = activeTabIdRef.current

View File

@ -4,6 +4,7 @@ import {
useContext, useContext,
useCallback, useCallback,
useEffect, useEffect,
useMemo,
useRef useRef
} from 'react' } from 'react'
import { useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'
@ -121,7 +122,14 @@ const NotificationProvider = ({ children }) => {
const unreadCount = notifications.filter((n) => !n.read).length const unreadCount = notifications.filter((n) => !n.read).length
// Initial load / when we become authenticated and connected const closeNotificationCenter = useCallback(() => {
setNotificationCenterVisible(false)
}, [])
useEffect(() => {
closeNotificationCenter()
}, [location.pathname, closeNotificationCenter])
useEffect(() => { useEffect(() => {
if (!authenticated || !connected) return if (!authenticated || !connected) return
fetchNotificationsRef.current() fetchNotificationsRef.current()
@ -134,10 +142,6 @@ const NotificationProvider = ({ children }) => {
fetchNotificationsRef.current() fetchNotificationsRef.current()
}, [notificationCenterVisible]) }, [notificationCenterVisible])
useEffect(() => {
setNotificationCenterVisible(false)
}, [location.pathname])
useEffect(() => { useEffect(() => {
if (!authenticated || !registerNotificationListener) return if (!authenticated || !registerNotificationListener) return
const handleNotification = (notif) => { const handleNotification = (notif) => {
@ -169,28 +173,42 @@ const NotificationProvider = ({ children }) => {
return unregister return unregister
}, [authenticated, registerNotificationListener, api, deleteNotification]) }, [authenticated, registerNotificationListener, api, deleteNotification])
const value = useMemo(
() => ({
notificationCenterVisible,
toggleNotificationCenter,
notifications,
fetchNotifications,
markNotificationAsRead,
markAllNotificationsAsRead,
deleteNotification,
deleteAllNotifications,
unreadCount,
notificationsLoading
}),
[
deleteAllNotifications,
deleteNotification,
fetchNotifications,
markAllNotificationsAsRead,
markNotificationAsRead,
notificationCenterVisible,
notifications,
notificationsLoading,
toggleNotificationCenter,
unreadCount
]
)
return ( return (
<NotificationContext.Provider <NotificationContext.Provider value={value}>
value={{
notificationCenterVisible,
toggleNotificationCenter,
notifications,
fetchNotifications,
markNotificationAsRead,
markAllNotificationsAsRead,
deleteNotification,
deleteAllNotifications,
unreadCount,
notificationsLoading
}}
>
{contextHolder} {contextHolder}
{children} {children}
<Drawer <Drawer
title='Notifications' title='Notifications'
placement='right' placement='right'
width={isMobile ? '100%' : 460} width={isMobile ? '100%' : 460}
onClose={() => setNotificationCenterVisible(false)} onClose={closeNotificationCenter}
open={notificationCenterVisible} open={notificationCenterVisible}
> >
<NotificationCenter <NotificationCenter

View File

@ -2,6 +2,7 @@ import {
createContext, createContext,
useCallback, useCallback,
useContext, useContext,
useMemo,
useState, useState,
useEffect useEffect
} from 'react' } from 'react'
@ -88,24 +89,27 @@ export const ThemeProvider = ({ children }) => {
) )
}, [showNavigationLabels]) }, [showNavigationLabels])
const toggleTheme = () => { const toggleTheme = useCallback(() => {
if (isSystem) { setIsSystem((current) => {
setIsSystem(false) if (current) return false
} return current
setIsDarkMode(!isDarkMode) })
} setIsDarkMode((current) => !current)
}, [])
const toggleSystem = () => { const toggleSystem = useCallback(() => {
setIsSystem(!isSystem) setIsSystem((current) => {
if (!isSystem) { const next = !current
// When enabling system theme, update to match system preference if (next) {
setIsDarkMode(window.matchMedia('(prefers-color-scheme: dark)').matches) setIsDarkMode(window.matchMedia('(prefers-color-scheme: dark)').matches)
} }
} return next
})
}, [])
const toggleCompact = () => { const toggleCompact = useCallback(() => {
setIsCompact(!isCompact) setIsCompact((current) => !current)
} }, [])
const setThemeMode = useCallback((value) => { const setThemeMode = useCallback((value) => {
if (value === 'system') { if (value === 'system') {
@ -126,19 +130,63 @@ export const ThemeProvider = ({ children }) => {
setShowNavigationLabelsState(Boolean(value)) setShowNavigationLabelsState(Boolean(value))
}, []) }, [])
const getThemeAlgorithm = () => { const getColors = useCallback(() => COLORS, [])
var baseAlgorithm
if (isDarkMode == true) {
baseAlgorithm = theme.darkAlgorithm
} else {
baseAlgorithm = theme.defaultAlgorithm
}
return isCompact ? [theme.compactAlgorithm, baseAlgorithm] : [baseAlgorithm]
}
const getColors = () => { const themeConfig = useMemo(() => {
return COLORS const baseAlgorithm = isDarkMode
} ? theme.darkAlgorithm
: theme.defaultAlgorithm
return {
algorithm: isCompact
? [theme.compactAlgorithm, baseAlgorithm]
: [baseAlgorithm],
token: {
...COLORS,
colorPrimary:
primaryColorOverride == null
? COLORS.colorPrimary
: primaryColorOverride,
borderRadius: '12px'
},
components: {
Layout: {
headerBg: isDarkMode ? '#141414' : '#ffffff'
}
}
}
}, [isDarkMode, isCompact, primaryColorOverride])
const value = useMemo(
() => ({
isDarkMode,
toggleTheme,
isCompact,
toggleCompact,
isSystem,
toggleSystem,
setThemeMode,
setDensityMode,
showNavigationLabels,
setShowNavigationLabels,
getColors,
setPrimaryColorOverride,
themeConfig
}),
[
getColors,
isCompact,
isDarkMode,
isSystem,
setDensityMode,
setShowNavigationLabels,
setThemeMode,
showNavigationLabels,
themeConfig,
toggleCompact,
toggleSystem,
toggleTheme
]
)
// Set CSS custom properties for theme colors // Set CSS custom properties for theme colors
useEffect(() => { useEffect(() => {
@ -226,41 +274,8 @@ export const ThemeProvider = ({ children }) => {
) )
}, [isDarkMode, primaryColorOverride]) }, [isDarkMode, primaryColorOverride])
const themeConfig = {
algorithm: getThemeAlgorithm(),
token: {
...COLORS,
colorPrimary:
primaryColorOverride == null
? COLORS.colorPrimary
: primaryColorOverride,
borderRadius: '12px'
},
components: {
Layout: {
headerBg: isDarkMode ? '#141414' : '#ffffff'
}
}
}
return ( return (
<ThemeContext.Provider <ThemeContext.Provider value={value}>
value={{
isDarkMode,
toggleTheme,
isCompact,
toggleCompact,
isSystem,
toggleSystem,
setThemeMode,
setDensityMode,
showNavigationLabels,
setShowNavigationLabels,
getColors,
setPrimaryColorOverride,
themeConfig
}}
>
{children} {children}
</ThemeContext.Provider> </ThemeContext.Provider>
) )