diff --git a/src/App.jsx b/src/App.jsx
index 9eb031a..d88b4cf 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -18,6 +18,7 @@ import {
ElectronSpotlightContentPage
} from './components/Dashboard/context/SpotlightContext.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 {
@@ -46,7 +47,8 @@ import {
FinanceRoutes,
SalesRoutes,
ManagementRoutes,
- DeveloperRoutes
+ DeveloperRoutes,
+ ModelRoutes
} from './routes'
const getRouter = () => {
@@ -83,86 +85,89 @@ const AppContent = () => {
-
-
-
-
-
-
- }
- />
- (
-
- )}
- />
- }
- />
- (
-
- )}
- />
- }
- />
- }
- />
- }
- />
- }
- />
- }
- />
+
+
+
+
+
+
+
+ }
+ />
+ (
+
+ )}
+ />
+ }
+ />
+ (
+
+ )}
+ />
+ }
+ />
+ }
+ />
+ }
+ />
+ }
+ />
+ }
+ />
- }
- />
- }
- >
- {ProductionRoutes}
- {InventoryRoutes}
- {FinanceRoutes}
- {SalesRoutes}
- {ManagementRoutes}
- {DeveloperRoutes}
-
-
- }
- />
-
-
-
-
-
-
+ }
+ />
+ }
+ >
+ {ProductionRoutes}
+ {InventoryRoutes}
+ {FinanceRoutes}
+ {SalesRoutes}
+ {ManagementRoutes}
+ {ModelRoutes}
+ {DeveloperRoutes}
+
+
+ }
+ />
+
+
+
+
+
+
+
diff --git a/src/components/Dashboard/common/ActivityIndicator.jsx b/src/components/Dashboard/common/ActivityIndicator.jsx
index c60ba59..e6431d4 100644
--- a/src/components/Dashboard/common/ActivityIndicator.jsx
+++ b/src/components/Dashboard/common/ActivityIndicator.jsx
@@ -8,6 +8,7 @@ import SpotlightTooltip from './SpotlightTooltip'
import Thumbnail from './Thumbnail'
import { useNavigate } from 'react-router-dom'
import { getModelByName } from '../../../database/ObjectModels'
+import { buildActionUrl } from '../../../utils/modelActions'
const getUserId = (user) => user?._id || user
@@ -90,7 +91,13 @@ const UserActivityAvatar = memo(
model.actions?.filter((action) => action.default == true) || []
if (defaultModelActions.length >= 1 && getUserId(user)) {
- hyperlink = defaultModelActions[0].url(getUserId(user))
+ hyperlink = buildActionUrl(
+ model,
+ defaultModelActions[0],
+ getUserId(user),
+ '',
+ ''
+ )
}
return (
diff --git a/src/components/Dashboard/common/AlertsDisplay.jsx b/src/components/Dashboard/common/AlertsDisplay.jsx
index d14acd8..f64ab38 100644
--- a/src/components/Dashboard/common/AlertsDisplay.jsx
+++ b/src/components/Dashboard/common/AlertsDisplay.jsx
@@ -5,6 +5,7 @@ import ExclamationOctagonIcon from '../../Icons/ExclamationOctagonIcon'
import InfoCircleIcon from '../../Icons/InfoCircleIcon'
import XMarkIcon from '../../Icons/XMarkIcon'
import { getModelByName } from '../../../database/ObjectModels'
+import { buildActionUrl } from '../../../utils/modelActions'
import { useNavigate } from 'react-router-dom'
import ActionsIcon from '../../Icons/ActionsIcon'
import { ApiServerContext } from '../context/ApiServerContext'
@@ -152,8 +153,16 @@ const AlertsDisplay = ({
onClick: ({ key }) => {
const action = findActionByKey(filteredActions, key)
- if (action?.url) {
- navigate(action.url(objectId))
+ if (action) {
+ navigate(
+ buildActionUrl(
+ getModelByName(objectType),
+ action,
+ objectId,
+ '',
+ ''
+ )
+ )
} else {
console.warn('No action found for key:', key)
}
diff --git a/src/components/Dashboard/common/IdDisplay.jsx b/src/components/Dashboard/common/IdDisplay.jsx
index 1329c36..2f07fa0 100644
--- a/src/components/Dashboard/common/IdDisplay.jsx
+++ b/src/components/Dashboard/common/IdDisplay.jsx
@@ -6,6 +6,7 @@ import { useMediaQuery } from 'react-responsive'
import CopyButton from './CopyButton'
import SpotlightTooltip from './SpotlightTooltip'
import { getModelByName } from '../../../database/ObjectModels'
+import { buildActionUrl } from '../../../utils/modelActions'
const { Text, Link } = Typography
@@ -31,7 +32,7 @@ const IdDisplay = ({
model.actions?.filter((action) => action.default == true) || []
if (defaultModelActions.length >= 1) {
- hyperlink = defaultModelActions[0].url(id)
+ hyperlink = buildActionUrl(model, defaultModelActions[0], id, '', '')
}
if (!id) {
diff --git a/src/components/Dashboard/common/ModelPage.jsx b/src/components/Dashboard/common/ModelPage.jsx
new file mode 100644
index 0000000..7bab95e
--- /dev/null
+++ b/src/components/Dashboard/common/ModelPage.jsx
@@ -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 (
+
+ } />
+
+ }
+ >
+ {page.content()}
+
+ )
+}
+
+ModelPage.propTypes = {
+ modelName: PropTypes.string.isRequired,
+ pageName: PropTypes.string.isRequired
+}
+
+export default ModelPage
diff --git a/src/components/Dashboard/common/ObjectActions.jsx b/src/components/Dashboard/common/ObjectActions.jsx
index b5cbe2c..41db9ee 100644
--- a/src/components/Dashboard/common/ObjectActions.jsx
+++ b/src/components/Dashboard/common/ObjectActions.jsx
@@ -6,6 +6,7 @@ import { useNavigate, useLocation } from 'react-router-dom'
import { useActionsModal } from '../context/ActionsModalContext'
import KeyboardShortcut from './KeyboardShortcut'
import { AuthContext } from '../context/AuthContext'
+import { buildActionUrl, stripPageActionParams } from '../../../utils/modelActions'
// Recursively filter actions based on visibleActions
function filterActionsByVisibility(actions, visibleActions) {
@@ -69,22 +70,43 @@ function cleanDividers(items) {
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
function mapActionsToMenuItems(
actions,
currentUrlWithActions,
id,
objectData,
- userProfile
+ userProfile,
+ model,
+ pathname,
+ search
) {
return cleanDividers(
actions.map((action) => {
if (action.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
if (action.disabled) {
@@ -120,7 +142,10 @@ function mapActionsToMenuItems(
currentUrlWithActions,
id,
objectData,
- userProfile
+ userProfile,
+ model,
+ pathname,
+ search
)
}
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 = ({
type,
id,
@@ -150,11 +168,6 @@ const ObjectActions = ({
const location = useLocation()
const { showActionsModal } = useActionsModal()
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
const visibilityFilteredActions = filterActionsByVisibility(
@@ -163,12 +176,23 @@ const ObjectActions = ({
)
const filteredActions = cleanDividers(
- visibilityFilteredActions.filter(
- (action) =>
- action.type === 'divider' ||
- typeof action.url !== 'function' ||
- action.url(id) !== currentUrlWithoutActions
- )
+ visibilityFilteredActions.filter((action) => {
+ if (action.type === 'divider') return true
+ const actionUrl = buildActionUrl(
+ model,
+ action,
+ id,
+ location.pathname,
+ location.search
+ )
+ return !isSameActionUrl(
+ action,
+ actionUrl,
+ location.pathname + location.search,
+ location.pathname,
+ location.search
+ )
+ })
)
const currentUrlWithActions = location.pathname + location.search
@@ -180,7 +204,10 @@ const ObjectActions = ({
currentUrlWithActions,
id,
objectData,
- userProfile
+ userProfile,
+ model,
+ location.pathname,
+ location.search
),
onClick: (info) => {
// Find the action by key
@@ -195,8 +222,10 @@ const ObjectActions = ({
return null
}
const action = findAction(filteredActions, info.key)
- if (action && action.url) {
- navigate(action.url(id))
+ if (action) {
+ navigate(
+ buildActionUrl(model, action, id, location.pathname, location.search)
+ )
}
}
}
diff --git a/src/components/Dashboard/common/ObjectDisplay.jsx b/src/components/Dashboard/common/ObjectDisplay.jsx
index 57fa390..2e1ab15 100644
--- a/src/components/Dashboard/common/ObjectDisplay.jsx
+++ b/src/components/Dashboard/common/ObjectDisplay.jsx
@@ -4,6 +4,7 @@ import { LoadingOutlined } from '@ant-design/icons'
import { useState, useEffect, useContext, useCallback, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { getModelByName } from '../../../database/ObjectModels'
+import { buildActionUrl } from '../../../utils/modelActions'
import { ApiServerContext } from '../context/ApiServerContext'
import { AuthContext } from '../context/AuthContext'
import merge from 'lodash/merge'
@@ -182,7 +183,13 @@ const ObjectDisplay = ({
const objectId = getStringId(objectData)
if (defaultModelActions.length >= 1 && objectId) {
- hyperlink = defaultModelActions[0].url(objectId)
+ hyperlink = buildActionUrl(
+ model,
+ defaultModelActions[0],
+ objectId,
+ '',
+ ''
+ )
}
// Render name with hyperlink/spotlight support
diff --git a/src/components/Dashboard/common/ObjectForm.jsx b/src/components/Dashboard/common/ObjectForm.jsx
index 89cb19f..2bf20b1 100644
--- a/src/components/Dashboard/common/ObjectForm.jsx
+++ b/src/components/Dashboard/common/ObjectForm.jsx
@@ -18,6 +18,7 @@ import set from 'lodash/set'
import { getModelByName } from '../../../database/ObjectModels'
import { useLocation, useNavigate } from 'react-router-dom'
import PropTypes from 'prop-types'
+import { useActions } from '../context/ActionsContext'
const arrayReplaceCustomizer = (objValue, srcValue) => {
if (Array.isArray(srcValue)) {
@@ -86,9 +87,13 @@ const buildObjectFromEntries = (entries = []) => {
* - children: function({
* loading, isEditing, startEditing, cancelEditing, handleUpdate, form, formValid, objectData, setIsEditing, setObjectData
* }) => ReactNode
+ * - setCurrentObject: boolean (sync object data to ActionsContext)
*/
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 serverObjectData = useRef(null)
const onStateChangeRef = useRef(onStateChange)
@@ -118,6 +123,10 @@ const ObjectForm = forwardRef(
flushFile
} = useContext(ApiServerContext)
const { token, userProfile } = useContext(AuthContext)
+ const {
+ setCurrentObject: setActionsCurrentObject,
+ setCurrentObjectType: setActionsCurrentObjectType
+ } = useActions()
const location = useLocation()
const navigate = useNavigate()
@@ -588,6 +597,32 @@ const ObjectForm = forwardRef(
return () => clearTimeout(timeoutId)
}, [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 () => {
try {
const latestActivities = await fetchObjectActivities(id, type)
@@ -814,7 +849,8 @@ ObjectForm.propTypes = {
children: PropTypes.func.isRequired,
style: PropTypes.object,
onEdit: PropTypes.func,
- onStateChange: PropTypes.func
+ onStateChange: PropTypes.func,
+ setCurrentObject: PropTypes.bool
}
export default ObjectForm
diff --git a/src/components/Dashboard/common/ObjectTable.jsx b/src/components/Dashboard/common/ObjectTable.jsx
index e811243..7b582ac 100644
--- a/src/components/Dashboard/common/ObjectTable.jsx
+++ b/src/components/Dashboard/common/ObjectTable.jsx
@@ -39,10 +39,11 @@ import ObjectCard from './ObjectCard'
import FilterSidebar from './FilterSidebar'
import XMarkIcon from '../../Icons/XMarkIcon'
import CheckIcon from '../../Icons/CheckIcon'
-import { useNavigate, useLocation } from 'react-router-dom'
+import { useLocation } from 'react-router-dom'
import QuestionCircleIcon from '../../Icons/QuestionCircleIcon'
import { AuthContext } from '../context/AuthContext'
import { ElectronContext } from '../context/ElectronContext'
+import { useActions } from '../context/ActionsContext'
import ActionsIcon from '../../Icons/ActionsIcon'
import FilterIcon from '../../Icons/FilterIcon'
import ScrollBox from './ScrollBox'
@@ -130,10 +131,10 @@ const ObjectTable = forwardRef(
},
ref
) => {
- const { token } = useContext(AuthContext)
+ const { token, userProfile } = useContext(AuthContext)
const { isElectron } = useContext(ElectronContext)
+ const { callAction } = useActions()
const onStateChangeRef = useRef(onStateChange)
- const { userProfile } = useContext(AuthContext)
useEffect(() => {
onStateChangeRef.current = onStateChange
}, [onStateChange])
@@ -147,7 +148,6 @@ const ObjectTable = forwardRef(
clearObjectActivity
} = useContext(ApiServerContext)
const isMobile = useMediaQuery({ maxWidth: 768 })
- const navigate = useNavigate()
const location = useLocation()
const {
getPersistedFilter,
@@ -292,9 +292,11 @@ const ObjectTable = forwardRef(
) {
return
}
- if (action.url) {
- navigate(action.url(objectData._id))
- }
+ callAction(
+ action,
+ { ...objectData, _user: userProfile },
+ type
+ )
}}
/>
diff --git a/src/components/Dashboard/common/UserProfilePopover.jsx b/src/components/Dashboard/common/UserProfilePopover.jsx
index cc5988d..570884b 100644
--- a/src/components/Dashboard/common/UserProfilePopover.jsx
+++ b/src/components/Dashboard/common/UserProfilePopover.jsx
@@ -6,6 +6,7 @@ import { useContext } from 'react'
import { useNavigate } from 'react-router-dom'
import LogoutIcon from '../../Icons/LogoutIcon'
import { User } from '../../../database/models/User'
+import { buildActionUrl } from '../../../utils/modelActions'
import { AuthContext } from '../context/AuthContext'
const { Text } = Typography
@@ -29,8 +30,8 @@ const UserProfilePopover = ({ onClose }) => {
const runAction = (action) => {
if (action.name === 'logout') {
logout()
- } else if (action.url && userProfile?._id) {
- const url = action.url(userProfile._id)
+ } else if (userProfile?._id) {
+ const url = buildActionUrl(User, action, userProfile._id, '', '')
navigate(url)
}
onClose?.()
diff --git a/src/components/Dashboard/context/ActionsContext.jsx b/src/components/Dashboard/context/ActionsContext.jsx
new file mode 100644
index 0000000..0cc842a
--- /dev/null
+++ b/src/components/Dashboard/context/ActionsContext.jsx
@@ -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 (
+
+
+
+ } />
+
+ }
+ >
+ {modalAction?.content && modalObjectData
+ ? modalAction.content(modalObjectData, { onOk: handleModalOk })
+ : null}
+
+
+ {children}
+
+ )
+}
+
+ActionsProvider.propTypes = {
+ children: PropTypes.node.isRequired
+}
+
+const useActions = () => useContext(ActionsContext)
+
+// eslint-disable-next-line react-refresh/only-export-components
+export { ActionsProvider, ActionsContext, useActions }
diff --git a/src/components/Dashboard/context/ActionsModalContext.jsx b/src/components/Dashboard/context/ActionsModalContext.jsx
index 1816413..7bc4d85 100644
--- a/src/components/Dashboard/context/ActionsModalContext.jsx
+++ b/src/components/Dashboard/context/ActionsModalContext.jsx
@@ -12,16 +12,15 @@ import { useLocation, useNavigate } from 'react-router-dom'
import { getModelByName } from '../../../database/ObjectModels'
import { AuthContext } from './AuthContext'
+import {
+ buildActionUrl,
+ stripActionParams
+} from '../../../utils/modelActions'
const ActionsModalContext = createContext()
-// Remove the "action" query param from a URL so we don't navigate to the same URL again
-const stripActionParam = (pathname, search) => {
- const params = new URLSearchParams(search)
- params.delete('action')
- const query = params.toString()
- return pathname + (query ? `?${query}` : '')
-}
+// Remove action query params from a URL so we don't navigate to the same URL again
+const stripActionParam = stripActionParams
// Flatten nested actions (including children) into a single list
const flattenActions = (actions, parentLabel = '') => {
@@ -34,7 +33,10 @@ const flattenActions = (actions, parentLabel = '') => {
return
}
- const hasUrl = typeof action.url === 'function'
+ const hasUrl =
+ typeof action.url === 'function' ||
+ action.type === 'page' ||
+ action.type === 'modal'
const hasChildren =
Array.isArray(action.children) && action.children.length > 0
@@ -122,7 +124,13 @@ const ActionsModalProvider = ({ children }) => {
if (!action) return true
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
if (url && url === currentUrlWithoutActions) {
@@ -174,11 +182,18 @@ const ActionsModalProvider = ({ children }) => {
})
const runAction = (action) => {
- if (!action || typeof action.url !== 'function') return
+ if (!action) return
if (getActionDisabled(action)) return
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 !== '#') {
navigate(targetUrl)
hideActionsModal()
diff --git a/src/components/Dashboard/context/SpotlightContext.jsx b/src/components/Dashboard/context/SpotlightContext.jsx
index 3833630..eba668d 100644
--- a/src/components/Dashboard/context/SpotlightContext.jsx
+++ b/src/components/Dashboard/context/SpotlightContext.jsx
@@ -33,6 +33,7 @@ import {
getModelByPrefix,
searchModelsByLabel
} from '../../../database/ObjectModels'
+import { buildActionUrl } from '../../../utils/modelActions'
import InfoCircleIcon from '../../Icons/InfoCircleIcon'
import MenuIcon from '../../Icons/MenuIcon'
import { ApiServerContext } from './ApiServerContext'
@@ -283,11 +284,10 @@ const SpotlightContent = ({
const getDefaultRowAction = (model) =>
getRowActions(model).find((action) => action.default)
- const triggerRowAction = (action, item) => {
- if (action && action.url) {
- // Try to get the id (support _id or id)
+ const triggerRowAction = (action, item, model) => {
+ if (action) {
const itemId = item._id || item.id
- const url = action.url(itemId)
+ const url = buildActionUrl(model, action, itemId, '', '')
if (url && url !== '#') {
if (isElectron) {
openInternalUrl(url)
@@ -314,7 +314,7 @@ const SpotlightContent = ({
? getDefaultRowAction(model)
: null
if (defaultAction) {
- triggerRowAction(defaultAction, item)
+ triggerRowAction(defaultAction, item, model)
}
return
}
@@ -332,7 +332,7 @@ const SpotlightContent = ({
? getDefaultRowAction(model)
: null
if (defaultAction) {
- triggerRowAction(defaultAction, item)
+ triggerRowAction(defaultAction, item, model)
}
}
return
@@ -539,7 +539,7 @@ const SpotlightContent = ({
}
size={'small'}
type={'text'}
- onClick={() => triggerRowAction(action, item)}
+ onClick={() => triggerRowAction(action, item, model)}
/>
))}
{rowActions
@@ -556,7 +556,7 @@ const SpotlightContent = ({
}
size={'small'}
type={'text'}
- onClick={() => triggerRowAction(action, item)}
+ onClick={() => triggerRowAction(action, item, model)}
/>
))}
{shortcutText && {shortcutText}}
diff --git a/src/routes/ModelRoutes.jsx b/src/routes/ModelRoutes.jsx
new file mode 100644
index 0000000..2d5aae0
--- /dev/null
+++ b/src/routes/ModelRoutes.jsx
@@ -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) => (
+
+ }
+ />
+ ))
+ )
+
+export default ModelRoutes
diff --git a/src/routes/index.js b/src/routes/index.js
index 241e8e9..2f7885f 100644
--- a/src/routes/index.js
+++ b/src/routes/index.js
@@ -4,3 +4,4 @@ export { default as FinanceRoutes } from './FinanceRoutes'
export { default as SalesRoutes } from './SalesRoutes'
export { default as ManagementRoutes } from './ManagementRoutes'
export { default as DeveloperRoutes } from './DeveloperRoutes'
+export { default as ModelRoutes } from './ModelRoutes'
diff --git a/src/utils/modelActions.js b/src/utils/modelActions.js
new file mode 100644
index 0000000..fabdd95
--- /dev/null
+++ b/src/utils/modelActions.js
@@ -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)
+}