Add ModelRoutes and ActionsContext for enhanced model management

- Introduced ModelRoutes to handle dynamic routing for model pages based on object models.
- Created ActionsContext to manage actions and modal states across components, improving action handling and navigation.
- Updated various components to utilize the new buildActionUrl utility for consistent URL generation.
- Refactored existing components to integrate the ActionsProvider, enhancing state management and user interaction.
This commit is contained in:
Tom Butcher 2026-08-01 18:25:29 +01:00
parent c3cfd0a55e
commit 89b6f476c1
16 changed files with 623 additions and 140 deletions

View File

@ -18,6 +18,7 @@ import {
ElectronSpotlightContentPage ElectronSpotlightContentPage
} from './components/Dashboard/context/SpotlightContext.jsx' } from './components/Dashboard/context/SpotlightContext.jsx'
import { ActionsModalProvider } from './components/Dashboard/context/ActionsModalContext.jsx' import { ActionsModalProvider } from './components/Dashboard/context/ActionsModalContext.jsx'
import { ActionsProvider } from './components/Dashboard/context/ActionsContext.jsx'
import MissingPlaceholder from './components/Dashboard/common/MissingPlaceholder.jsx' import MissingPlaceholder from './components/Dashboard/common/MissingPlaceholder.jsx'
import { import {
@ -46,7 +47,8 @@ import {
FinanceRoutes, FinanceRoutes,
SalesRoutes, SalesRoutes,
ManagementRoutes, ManagementRoutes,
DeveloperRoutes DeveloperRoutes,
ModelRoutes
} from './routes' } from './routes'
const getRouter = () => { const getRouter = () => {
@ -83,11 +85,12 @@ const AppContent = () => {
<PrintServerProvider> <PrintServerProvider>
<ApiServerProvider> <ApiServerProvider>
<MessageProvider> <MessageProvider>
<TableStateProvider>
<AppUpdateProvider> <AppUpdateProvider>
<NotificationProvider> <NotificationProvider>
<SpotlightProvider> <SpotlightProvider>
<ActionsModalProvider> <ActionsModalProvider>
<TableStateProvider> <ActionsProvider>
<Routes> <Routes>
<Route <Route
path='/applaunch' path='/applaunch'
@ -146,6 +149,7 @@ const AppContent = () => {
{FinanceRoutes} {FinanceRoutes}
{SalesRoutes} {SalesRoutes}
{ManagementRoutes} {ManagementRoutes}
{ModelRoutes}
{DeveloperRoutes} {DeveloperRoutes}
</Route> </Route>
<Route <Route
@ -158,11 +162,12 @@ const AppContent = () => {
} }
/> />
</Routes> </Routes>
</TableStateProvider> </ActionsProvider>
</ActionsModalProvider> </ActionsModalProvider>
</SpotlightProvider> </SpotlightProvider>
</NotificationProvider> </NotificationProvider>
</AppUpdateProvider> </AppUpdateProvider>
</TableStateProvider>
</MessageProvider> </MessageProvider>
</ApiServerProvider> </ApiServerProvider>
</PrintServerProvider> </PrintServerProvider>

View File

@ -8,6 +8,7 @@ import SpotlightTooltip from './SpotlightTooltip'
import Thumbnail from './Thumbnail' import Thumbnail from './Thumbnail'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { getModelByName } from '../../../database/ObjectModels' import { getModelByName } from '../../../database/ObjectModels'
import { buildActionUrl } from '../../../utils/modelActions'
const getUserId = (user) => user?._id || user const getUserId = (user) => user?._id || user
@ -90,7 +91,13 @@ const UserActivityAvatar = memo(
model.actions?.filter((action) => action.default == true) || [] model.actions?.filter((action) => action.default == true) || []
if (defaultModelActions.length >= 1 && getUserId(user)) { if (defaultModelActions.length >= 1 && getUserId(user)) {
hyperlink = defaultModelActions[0].url(getUserId(user)) hyperlink = buildActionUrl(
model,
defaultModelActions[0],
getUserId(user),
'',
''
)
} }
return ( return (

View File

@ -5,6 +5,7 @@ import ExclamationOctagonIcon from '../../Icons/ExclamationOctagonIcon'
import InfoCircleIcon from '../../Icons/InfoCircleIcon' import InfoCircleIcon from '../../Icons/InfoCircleIcon'
import XMarkIcon from '../../Icons/XMarkIcon' import XMarkIcon from '../../Icons/XMarkIcon'
import { getModelByName } from '../../../database/ObjectModels' import { getModelByName } from '../../../database/ObjectModels'
import { buildActionUrl } from '../../../utils/modelActions'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import ActionsIcon from '../../Icons/ActionsIcon' import ActionsIcon from '../../Icons/ActionsIcon'
import { ApiServerContext } from '../context/ApiServerContext' import { ApiServerContext } from '../context/ApiServerContext'
@ -152,8 +153,16 @@ const AlertsDisplay = ({
onClick: ({ key }) => { onClick: ({ key }) => {
const action = findActionByKey(filteredActions, key) const action = findActionByKey(filteredActions, key)
if (action?.url) { if (action) {
navigate(action.url(objectId)) navigate(
buildActionUrl(
getModelByName(objectType),
action,
objectId,
'',
''
)
)
} else { } else {
console.warn('No action found for key:', key) console.warn('No action found for key:', key)
} }

View File

@ -6,6 +6,7 @@ import { useMediaQuery } from 'react-responsive'
import CopyButton from './CopyButton' import CopyButton from './CopyButton'
import SpotlightTooltip from './SpotlightTooltip' import SpotlightTooltip from './SpotlightTooltip'
import { getModelByName } from '../../../database/ObjectModels' import { getModelByName } from '../../../database/ObjectModels'
import { buildActionUrl } from '../../../utils/modelActions'
const { Text, Link } = Typography const { Text, Link } = Typography
@ -31,7 +32,7 @@ const IdDisplay = ({
model.actions?.filter((action) => action.default == true) || [] model.actions?.filter((action) => action.default == true) || []
if (defaultModelActions.length >= 1) { if (defaultModelActions.length >= 1) {
hyperlink = defaultModelActions[0].url(id) hyperlink = buildActionUrl(model, defaultModelActions[0], id, '', '')
} }
if (!id) { if (!id) {

View File

@ -0,0 +1,56 @@
import { Suspense, useContext, useEffect } from 'react'
import { useLocation } from 'react-router-dom'
import { Flex, Spin } from 'antd'
import { LoadingOutlined } from '@ant-design/icons'
import PropTypes from 'prop-types'
import { getModelByName } from '../../../database/ObjectModels'
import { getObjectIdFromSearch } from '../../../utils/modelActions'
import { useActions } from '../context/ActionsContext'
import { AuthContext } from '../context/AuthContext'
const ModelPage = ({ modelName, pageName }) => {
const model = getModelByName(modelName)
const page = model.pages?.find((p) => p.name === pageName)
const location = useLocation()
const { setCurrentObject, setCurrentObjectType } = useActions()
const { userProfile } = useContext(AuthContext)
const objectId = getObjectIdFromSearch(modelName, location.search)
useEffect(() => {
setCurrentObjectType(modelName)
if (objectId) {
setCurrentObject({ _id: objectId, _user: userProfile })
}
return () => {
setCurrentObject(null)
setCurrentObjectType(null)
}
}, [modelName, objectId, setCurrentObject, setCurrentObjectType, userProfile])
if (!page?.content) {
return null
}
return (
<Suspense
fallback={
<Flex
justify='center'
align='center'
style={{ width: '100%', height: '100%' }}
>
<Spin indicator={<LoadingOutlined spin />} />
</Flex>
}
>
{page.content()}
</Suspense>
)
}
ModelPage.propTypes = {
modelName: PropTypes.string.isRequired,
pageName: PropTypes.string.isRequired
}
export default ModelPage

View File

@ -6,6 +6,7 @@ import { useNavigate, useLocation } from 'react-router-dom'
import { useActionsModal } from '../context/ActionsModalContext' import { useActionsModal } from '../context/ActionsModalContext'
import KeyboardShortcut from './KeyboardShortcut' import KeyboardShortcut from './KeyboardShortcut'
import { AuthContext } from '../context/AuthContext' import { AuthContext } from '../context/AuthContext'
import { buildActionUrl, stripPageActionParams } from '../../../utils/modelActions'
// Recursively filter actions based on visibleActions // Recursively filter actions based on visibleActions
function filterActionsByVisibility(actions, visibleActions) { function filterActionsByVisibility(actions, visibleActions) {
@ -69,22 +70,43 @@ function cleanDividers(items) {
return cleaned return cleaned
} }
function isSameActionUrl(action, actionUrl, currentUrl, pathname, search) {
if (action.type === 'modal') {
return actionUrl === currentUrl
}
return (
stripPageActionParams(
actionUrl.split('?')[0],
actionUrl.includes('?') ? actionUrl.split('?')[1] : ''
) === stripPageActionParams(pathname, search)
)
}
// Recursively map actions to AntD Dropdown items // Recursively map actions to AntD Dropdown items
function mapActionsToMenuItems( function mapActionsToMenuItems(
actions, actions,
currentUrlWithActions, currentUrlWithActions,
id, id,
objectData, objectData,
userProfile userProfile,
model,
pathname,
search
) { ) {
return cleanDividers( return cleanDividers(
actions.map((action) => { actions.map((action) => {
if (action.type === 'divider') { if (action.type === 'divider') {
return { type: 'divider' } return { type: 'divider' }
} }
const actionUrl = action.url ? action.url(id) : undefined const actionUrl = buildActionUrl(model, action, id, pathname, search)
var disabled = actionUrl && actionUrl === currentUrlWithActions var disabled = isSameActionUrl(
action,
actionUrl,
currentUrlWithActions,
pathname,
search
)
var visible = true var visible = true
if (action.disabled) { if (action.disabled) {
@ -120,7 +142,10 @@ function mapActionsToMenuItems(
currentUrlWithActions, currentUrlWithActions,
id, id,
objectData, objectData,
userProfile userProfile,
model,
pathname,
search
) )
} }
return item return item
@ -128,13 +153,6 @@ function mapActionsToMenuItems(
) )
} }
const stripActionParam = (pathname, search) => {
const params = new URLSearchParams(search)
params.delete('action')
const query = params.toString()
return pathname + (query ? `?${query}` : '')
}
const ObjectActions = ({ const ObjectActions = ({
type, type,
id, id,
@ -150,11 +168,6 @@ const ObjectActions = ({
const location = useLocation() const location = useLocation()
const { showActionsModal } = useActionsModal() const { showActionsModal } = useActionsModal()
const { userProfile } = useContext(AuthContext) const { userProfile } = useContext(AuthContext)
// Get current url without 'action' param
const currentUrlWithoutActions = stripActionParam(
location.pathname,
location.search
)
// First filter by visibility, then by current URL // First filter by visibility, then by current URL
const visibilityFilteredActions = filterActionsByVisibility( const visibilityFilteredActions = filterActionsByVisibility(
@ -163,12 +176,23 @@ const ObjectActions = ({
) )
const filteredActions = cleanDividers( const filteredActions = cleanDividers(
visibilityFilteredActions.filter( visibilityFilteredActions.filter((action) => {
(action) => if (action.type === 'divider') return true
action.type === 'divider' || const actionUrl = buildActionUrl(
typeof action.url !== 'function' || model,
action.url(id) !== currentUrlWithoutActions action,
id,
location.pathname,
location.search
) )
return !isSameActionUrl(
action,
actionUrl,
location.pathname + location.search,
location.pathname,
location.search
)
})
) )
const currentUrlWithActions = location.pathname + location.search const currentUrlWithActions = location.pathname + location.search
@ -180,7 +204,10 @@ const ObjectActions = ({
currentUrlWithActions, currentUrlWithActions,
id, id,
objectData, objectData,
userProfile userProfile,
model,
location.pathname,
location.search
), ),
onClick: (info) => { onClick: (info) => {
// Find the action by key // Find the action by key
@ -195,8 +222,10 @@ const ObjectActions = ({
return null return null
} }
const action = findAction(filteredActions, info.key) const action = findAction(filteredActions, info.key)
if (action && action.url) { if (action) {
navigate(action.url(id)) navigate(
buildActionUrl(model, action, id, location.pathname, location.search)
)
} }
} }
} }

View File

@ -4,6 +4,7 @@ import { LoadingOutlined } from '@ant-design/icons'
import { useState, useEffect, useContext, useCallback, useRef } from 'react' import { useState, useEffect, useContext, useCallback, useRef } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { getModelByName } from '../../../database/ObjectModels' import { getModelByName } from '../../../database/ObjectModels'
import { buildActionUrl } from '../../../utils/modelActions'
import { ApiServerContext } from '../context/ApiServerContext' import { ApiServerContext } from '../context/ApiServerContext'
import { AuthContext } from '../context/AuthContext' import { AuthContext } from '../context/AuthContext'
import merge from 'lodash/merge' import merge from 'lodash/merge'
@ -182,7 +183,13 @@ const ObjectDisplay = ({
const objectId = getStringId(objectData) const objectId = getStringId(objectData)
if (defaultModelActions.length >= 1 && objectId) { if (defaultModelActions.length >= 1 && objectId) {
hyperlink = defaultModelActions[0].url(objectId) hyperlink = buildActionUrl(
model,
defaultModelActions[0],
objectId,
'',
''
)
} }
// Render name with hyperlink/spotlight support // Render name with hyperlink/spotlight support

View File

@ -18,6 +18,7 @@ import set from 'lodash/set'
import { getModelByName } from '../../../database/ObjectModels' import { getModelByName } from '../../../database/ObjectModels'
import { useLocation, useNavigate } from 'react-router-dom' import { useLocation, useNavigate } from 'react-router-dom'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { useActions } from '../context/ActionsContext'
const arrayReplaceCustomizer = (objValue, srcValue) => { const arrayReplaceCustomizer = (objValue, srcValue) => {
if (Array.isArray(srcValue)) { if (Array.isArray(srcValue)) {
@ -86,9 +87,13 @@ const buildObjectFromEntries = (entries = []) => {
* - children: function({ * - children: function({
* loading, isEditing, startEditing, cancelEditing, handleUpdate, form, formValid, objectData, setIsEditing, setObjectData * loading, isEditing, startEditing, cancelEditing, handleUpdate, form, formValid, objectData, setIsEditing, setObjectData
* }) => ReactNode * }) => ReactNode
* - setCurrentObject: boolean (sync object data to ActionsContext)
*/ */
const ObjectForm = forwardRef( const ObjectForm = forwardRef(
({ id, type, style, children, onEdit, onStateChange }, ref) => { (
{ id, type, style, children, onEdit, onStateChange, setCurrentObject = false },
ref
) => {
const [objectData, setObjectData] = useState(null) const [objectData, setObjectData] = useState(null)
const serverObjectData = useRef(null) const serverObjectData = useRef(null)
const onStateChangeRef = useRef(onStateChange) const onStateChangeRef = useRef(onStateChange)
@ -118,6 +123,10 @@ const ObjectForm = forwardRef(
flushFile flushFile
} = useContext(ApiServerContext) } = useContext(ApiServerContext)
const { token, userProfile } = useContext(AuthContext) const { token, userProfile } = useContext(AuthContext)
const {
setCurrentObject: setActionsCurrentObject,
setCurrentObjectType: setActionsCurrentObjectType
} = useActions()
const location = useLocation() const location = useLocation()
const navigate = useNavigate() const navigate = useNavigate()
@ -588,6 +597,32 @@ const ObjectForm = forwardRef(
return () => clearTimeout(timeoutId) return () => clearTimeout(timeoutId)
}, [objectData]) }, [objectData])
useEffect(() => {
if (!setCurrentObject) {
return
}
setActionsCurrentObjectType(type)
return () => {
setActionsCurrentObject(null)
setActionsCurrentObjectType(null)
}
}, [
setCurrentObject,
type,
setActionsCurrentObject,
setActionsCurrentObjectType
])
useEffect(() => {
if (!setCurrentObject || !objectData?._id || objectData._id !== id) {
return
}
setActionsCurrentObject(objectData)
}, [setCurrentObject, objectData, id, setActionsCurrentObject])
const startEditing = async () => { const startEditing = async () => {
try { try {
const latestActivities = await fetchObjectActivities(id, type) const latestActivities = await fetchObjectActivities(id, type)
@ -814,7 +849,8 @@ ObjectForm.propTypes = {
children: PropTypes.func.isRequired, children: PropTypes.func.isRequired,
style: PropTypes.object, style: PropTypes.object,
onEdit: PropTypes.func, onEdit: PropTypes.func,
onStateChange: PropTypes.func onStateChange: PropTypes.func,
setCurrentObject: PropTypes.bool
} }
export default ObjectForm export default ObjectForm

View File

@ -39,10 +39,11 @@ import ObjectCard from './ObjectCard'
import FilterSidebar from './FilterSidebar' import FilterSidebar from './FilterSidebar'
import XMarkIcon from '../../Icons/XMarkIcon' import XMarkIcon from '../../Icons/XMarkIcon'
import CheckIcon from '../../Icons/CheckIcon' import CheckIcon from '../../Icons/CheckIcon'
import { useNavigate, useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'
import QuestionCircleIcon from '../../Icons/QuestionCircleIcon' 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 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'
@ -130,10 +131,10 @@ const ObjectTable = forwardRef(
}, },
ref ref
) => { ) => {
const { token } = useContext(AuthContext) const { token, userProfile } = useContext(AuthContext)
const { isElectron } = useContext(ElectronContext) const { isElectron } = useContext(ElectronContext)
const { callAction } = useActions()
const onStateChangeRef = useRef(onStateChange) const onStateChangeRef = useRef(onStateChange)
const { userProfile } = useContext(AuthContext)
useEffect(() => { useEffect(() => {
onStateChangeRef.current = onStateChange onStateChangeRef.current = onStateChange
}, [onStateChange]) }, [onStateChange])
@ -147,7 +148,6 @@ const ObjectTable = forwardRef(
clearObjectActivity clearObjectActivity
} = useContext(ApiServerContext) } = useContext(ApiServerContext)
const isMobile = useMediaQuery({ maxWidth: 768 }) const isMobile = useMediaQuery({ maxWidth: 768 })
const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const { const {
getPersistedFilter, getPersistedFilter,
@ -292,9 +292,11 @@ const ObjectTable = forwardRef(
) { ) {
return return
} }
if (action.url) { callAction(
navigate(action.url(objectData._id)) action,
} { ...objectData, _user: userProfile },
type
)
}} }}
/> />
</Tooltip> </Tooltip>

View File

@ -6,6 +6,7 @@ import { useContext } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import LogoutIcon from '../../Icons/LogoutIcon' import LogoutIcon from '../../Icons/LogoutIcon'
import { User } from '../../../database/models/User' import { User } from '../../../database/models/User'
import { buildActionUrl } from '../../../utils/modelActions'
import { AuthContext } from '../context/AuthContext' import { AuthContext } from '../context/AuthContext'
const { Text } = Typography const { Text } = Typography
@ -29,8 +30,8 @@ const UserProfilePopover = ({ onClose }) => {
const runAction = (action) => { const runAction = (action) => {
if (action.name === 'logout') { if (action.name === 'logout') {
logout() logout()
} else if (action.url && userProfile?._id) { } else if (userProfile?._id) {
const url = action.url(userProfile._id) const url = buildActionUrl(User, action, userProfile._id, '', '')
navigate(url) navigate(url)
} }
onClose?.() onClose?.()

View File

@ -0,0 +1,222 @@
import {
createContext,
Suspense,
useCallback,
useContext,
useEffect,
useRef,
useState
} from 'react'
import PropTypes from 'prop-types'
import { Modal, Spin, Flex } from 'antd'
import { LoadingOutlined } from '@ant-design/icons'
import { useLocation, useNavigate } from 'react-router-dom'
import { getModelByName } from '../../../database/ObjectModels'
import {
buildActionUrl,
findAction,
getObjectIdFromSearch,
stripModalActionParams
} from '../../../utils/modelActions'
import { AuthContext } from './AuthContext'
const ActionsContext = createContext()
const ActionsProvider = ({ children }) => {
const navigate = useNavigate()
const location = useLocation()
const { userProfile } = useContext(AuthContext)
const [currentObject, setCurrentObject] = useState(null)
const [currentObjectType, setCurrentObjectType] = useState(null)
const [modalAction, setModalAction] = useState(null)
const [onModalOk, setOnModalOk] = useState(null)
const lastHandledAction = useRef(null)
const searchParams = new URLSearchParams(location.search)
const actionName = searchParams.get('action')
const actionObjectType = searchParams.get('actionObjectType')
const clearAction = useCallback(() => {
const nextUrl = stripModalActionParams(location.pathname, location.search)
if (nextUrl !== location.pathname + location.search) {
navigate(nextUrl, { replace: true })
}
setModalAction(null)
lastHandledAction.current = null
}, [location.pathname, location.search, navigate])
const handleModalOk = useCallback(() => {
onModalOk?.()
clearAction()
}, [clearAction, onModalOk])
const callAction = useCallback(
(action, objectData, objectType) => {
setCurrentObject(objectData)
setCurrentObjectType(objectType)
const model = getModelByName(objectType)
const url = buildActionUrl(
model,
action,
objectData._id,
location.pathname,
location.search
)
navigate(url)
},
[location.pathname, location.search, navigate]
)
useEffect(() => {
if (!actionObjectType) return
const model = getModelByName(actionObjectType)
if (!model.pages?.length) return
const onPage = model.pages.some(
(page) => location.pathname === `${model.url}/${page.name}`
)
if (!onPage) return
if (actionName) {
const action = findAction(model, actionName)
if (action?.type === 'modal') return
}
const params = new URLSearchParams(location.search)
params.delete('actionObjectType')
const query = params.toString()
const nextUrl = location.pathname + (query ? `?${query}` : '')
if (nextUrl !== location.pathname + location.search) {
navigate(nextUrl, { replace: true })
}
}, [
actionName,
actionObjectType,
location.pathname,
location.search,
navigate
])
useEffect(() => {
if (!actionName || !actionObjectType) {
setModalAction(null)
lastHandledAction.current = null
return
}
const actionKey = `${actionObjectType}:${actionName}:${location.pathname}${location.search}`
if (lastHandledAction.current === actionKey) return
const model = getModelByName(actionObjectType)
const action = findAction(model, actionName)
if (!action) return
if (action.type === 'modal') {
lastHandledAction.current = actionKey
setModalAction(action)
return
}
if (action.type === 'page') {
const pageName = action.pageName || action.name
const objectId =
getObjectIdFromSearch(actionObjectType, location.search) ||
currentObject?._id
if (!objectId) return
const expectedPath = `${model.url}/${pageName}`
if (location.pathname === expectedPath) {
lastHandledAction.current = actionKey
return
}
lastHandledAction.current = actionKey
const url = buildActionUrl(
model,
action,
objectId,
location.pathname,
location.search
)
navigate(url, { replace: true })
}
}, [
actionName,
actionObjectType,
currentObject,
location.pathname,
location.search,
navigate
])
const modalObjectData = currentObject
? { ...currentObject, _user: userProfile }
: null
const [modelWidth, setModelWidth] = useState(520)
useEffect(() => {
if (modalAction?.modalWidth) {
setModelWidth(modalAction.modalWidth)
}
}, [modalAction?.modalWidth])
const [modalCentered, setModalCentered] = useState(false)
useEffect(() => {
if (modalAction?.modalCentered !== undefined) {
setModalCentered(modalAction.modalCentered)
}
}, [modalAction?.modalCentered])
return (
<ActionsContext.Provider
value={{
currentObject,
currentObjectType,
setCurrentObject,
setCurrentObjectType,
callAction,
clearAction,
setOnModalOk
}}
>
<Modal
open={modalAction != null}
destroyOnHidden={true}
width={modelWidth}
onCancel={clearAction}
footer={null}
centered={modalCentered}
>
<Suspense
fallback={
<Flex
justify='center'
align='center'
style={{
height: modalCentered ? '120px' : '357px',
width: '100%'
}}
>
<Spin indicator={<LoadingOutlined spin />} />
</Flex>
}
>
{modalAction?.content && modalObjectData
? modalAction.content(modalObjectData, { onOk: handleModalOk })
: null}
</Suspense>
</Modal>
{children}
</ActionsContext.Provider>
)
}
ActionsProvider.propTypes = {
children: PropTypes.node.isRequired
}
const useActions = () => useContext(ActionsContext)
// eslint-disable-next-line react-refresh/only-export-components
export { ActionsProvider, ActionsContext, useActions }

View File

@ -12,16 +12,15 @@ import { useLocation, useNavigate } from 'react-router-dom'
import { getModelByName } from '../../../database/ObjectModels' import { getModelByName } from '../../../database/ObjectModels'
import { AuthContext } from './AuthContext' import { AuthContext } from './AuthContext'
import {
buildActionUrl,
stripActionParams
} from '../../../utils/modelActions'
const ActionsModalContext = createContext() const ActionsModalContext = createContext()
// Remove the "action" query param from a URL so we don't navigate to the same URL again // Remove action query params from a URL so we don't navigate to the same URL again
const stripActionParam = (pathname, search) => { const stripActionParam = stripActionParams
const params = new URLSearchParams(search)
params.delete('action')
const query = params.toString()
return pathname + (query ? `?${query}` : '')
}
// Flatten nested actions (including children) into a single list // Flatten nested actions (including children) into a single list
const flattenActions = (actions, parentLabel = '') => { const flattenActions = (actions, parentLabel = '') => {
@ -34,7 +33,10 @@ const flattenActions = (actions, parentLabel = '') => {
return return
} }
const hasUrl = typeof action.url === 'function' const hasUrl =
typeof action.url === 'function' ||
action.type === 'page' ||
action.type === 'modal'
const hasChildren = const hasChildren =
Array.isArray(action.children) && action.children.length > 0 Array.isArray(action.children) && action.children.length > 0
@ -122,7 +124,13 @@ const ActionsModalProvider = ({ children }) => {
if (!action) return true if (!action) return true
let disabled = false let disabled = false
const url = action.url ? action.url(id) : undefined const url = buildActionUrl(
getModelByName(context.type),
action,
id,
location.pathname,
location.search
)
// Match ObjectActions default disabling behaviour // Match ObjectActions default disabling behaviour
if (url && url === currentUrlWithoutActions) { if (url && url === currentUrlWithoutActions) {
@ -174,11 +182,18 @@ const ActionsModalProvider = ({ children }) => {
}) })
const runAction = (action) => { const runAction = (action) => {
if (!action || typeof action.url !== 'function') return if (!action) return
if (getActionDisabled(action)) return if (getActionDisabled(action)) return
const { id } = context const { id } = context
const targetUrl = action.url(id) const model = getModelByName(context.type)
const targetUrl = buildActionUrl(
model,
action,
id,
location.pathname,
location.search
)
if (targetUrl && targetUrl !== '#') { if (targetUrl && targetUrl !== '#') {
navigate(targetUrl) navigate(targetUrl)
hideActionsModal() hideActionsModal()

View File

@ -33,6 +33,7 @@ import {
getModelByPrefix, getModelByPrefix,
searchModelsByLabel searchModelsByLabel
} from '../../../database/ObjectModels' } from '../../../database/ObjectModels'
import { buildActionUrl } from '../../../utils/modelActions'
import InfoCircleIcon from '../../Icons/InfoCircleIcon' import InfoCircleIcon from '../../Icons/InfoCircleIcon'
import MenuIcon from '../../Icons/MenuIcon' import MenuIcon from '../../Icons/MenuIcon'
import { ApiServerContext } from './ApiServerContext' import { ApiServerContext } from './ApiServerContext'
@ -283,11 +284,10 @@ const SpotlightContent = ({
const getDefaultRowAction = (model) => const getDefaultRowAction = (model) =>
getRowActions(model).find((action) => action.default) getRowActions(model).find((action) => action.default)
const triggerRowAction = (action, item) => { const triggerRowAction = (action, item, model) => {
if (action && action.url) { if (action) {
// Try to get the id (support _id or id)
const itemId = item._id || item.id const itemId = item._id || item.id
const url = action.url(itemId) const url = buildActionUrl(model, action, itemId, '', '')
if (url && url !== '#') { if (url && url !== '#') {
if (isElectron) { if (isElectron) {
openInternalUrl(url) openInternalUrl(url)
@ -314,7 +314,7 @@ const SpotlightContent = ({
? getDefaultRowAction(model) ? getDefaultRowAction(model)
: null : null
if (defaultAction) { if (defaultAction) {
triggerRowAction(defaultAction, item) triggerRowAction(defaultAction, item, model)
} }
return return
} }
@ -332,7 +332,7 @@ const SpotlightContent = ({
? getDefaultRowAction(model) ? getDefaultRowAction(model)
: null : null
if (defaultAction) { if (defaultAction) {
triggerRowAction(defaultAction, item) triggerRowAction(defaultAction, item, model)
} }
} }
return return
@ -539,7 +539,7 @@ const SpotlightContent = ({
} }
size={'small'} size={'small'}
type={'text'} type={'text'}
onClick={() => triggerRowAction(action, item)} onClick={() => triggerRowAction(action, item, model)}
/> />
))} ))}
{rowActions {rowActions
@ -556,7 +556,7 @@ const SpotlightContent = ({
} }
size={'small'} size={'small'}
type={'text'} type={'text'}
onClick={() => triggerRowAction(action, item)} onClick={() => triggerRowAction(action, item, model)}
/> />
))} ))}
{shortcutText && <Text keyboard>{shortcutText}</Text>} {shortcutText && <Text keyboard>{shortcutText}</Text>}

View File

@ -0,0 +1,20 @@
import { Route } from 'react-router-dom'
import { objectModels } from '../database/ObjectModels'
import { getModelRoutePath } from '../utils/modelActions'
import ModelPage from '../components/Dashboard/common/ModelPage'
const ModelRoutes = objectModels
.filter((model) => model.pages?.length > 0)
.flatMap((model) =>
model.pages.map((page) => (
<Route
key={`${model.name}-${page.name}`}
path={`${getModelRoutePath(model)}/${page.name}`}
element={
<ModelPage modelName={model.name} pageName={page.name} />
}
/>
))
)
export default ModelRoutes

View File

@ -4,3 +4,4 @@ export { default as FinanceRoutes } from './FinanceRoutes'
export { default as SalesRoutes } from './SalesRoutes' export { default as SalesRoutes } from './SalesRoutes'
export { default as ManagementRoutes } from './ManagementRoutes' export { default as ManagementRoutes } from './ManagementRoutes'
export { default as DeveloperRoutes } from './DeveloperRoutes' export { default as DeveloperRoutes } from './DeveloperRoutes'
export { default as ModelRoutes } from './ModelRoutes'

72
src/utils/modelActions.js Normal file
View File

@ -0,0 +1,72 @@
export function getObjectIdParamName(modelName) {
return `${modelName}Id`
}
export function getObjectIdFromSearch(modelName, search) {
const params = new URLSearchParams(search)
return params.get(getObjectIdParamName(modelName))
}
export function getModelRoutePath(model) {
return model.url.replace(/^\/dashboard\//, '')
}
export function buildActionUrl(model, action, objectId, pathname, search = '') {
if (typeof action.url === 'function') {
return action.url(objectId)
}
const idParam = getObjectIdParamName(model.name)
const pageName = action.pageName || action.name
if (action.type === 'page') {
const params = new URLSearchParams()
params.set(idParam, objectId)
if (action.name !== pageName) {
params.set('action', action.name)
}
return `/dashboard/${getModelRoutePath(model)}/${pageName}?${params.toString()}`
}
const params = new URLSearchParams(search)
params.set(idParam, objectId)
params.set('action', action.name)
params.set('actionObjectType', model.name)
const query = params.toString()
return `${pathname}${query ? `?${query}` : ''}`
}
export function stripPageActionParams(pathname, search) {
const params = new URLSearchParams(search)
params.delete('action')
const query = params.toString()
return pathname + (query ? `?${query}` : '')
}
export function stripModalActionParams(pathname, search) {
const params = new URLSearchParams(search)
params.delete('action')
params.delete('actionObjectType')
const query = params.toString()
return pathname + (query ? `?${query}` : '')
}
export function stripActionParams(pathname, search) {
return stripPageActionParams(pathname, search)
}
export function findAction(model, actionName) {
const findInActions = (actions) => {
if (!Array.isArray(actions)) return null
for (const action of actions) {
if (action.type === 'divider') continue
if ((action.key || action.name) === actionName) return action
if (action.children) {
const found = findInActions(action.children)
if (found) return found
}
}
return null
}
return findInActions(model?.actions)
}