From 89b6f476c174fed561687b7c221c7441d001f072 Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Sat, 1 Aug 2026 18:25:29 +0100 Subject: [PATCH] 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. --- src/App.jsx | 165 ++++++------- .../Dashboard/common/ActivityIndicator.jsx | 9 +- .../Dashboard/common/AlertsDisplay.jsx | 13 +- src/components/Dashboard/common/IdDisplay.jsx | 3 +- src/components/Dashboard/common/ModelPage.jsx | 56 +++++ .../Dashboard/common/ObjectActions.jsx | 79 +++++-- .../Dashboard/common/ObjectDisplay.jsx | 9 +- .../Dashboard/common/ObjectForm.jsx | 40 +++- .../Dashboard/common/ObjectTable.jsx | 16 +- .../Dashboard/common/UserProfilePopover.jsx | 5 +- .../Dashboard/context/ActionsContext.jsx | 222 ++++++++++++++++++ .../Dashboard/context/ActionsModalContext.jsx | 37 ++- .../Dashboard/context/SpotlightContext.jsx | 16 +- src/routes/ModelRoutes.jsx | 20 ++ src/routes/index.js | 1 + src/utils/modelActions.js | 72 ++++++ 16 files changed, 623 insertions(+), 140 deletions(-) create mode 100644 src/components/Dashboard/common/ModelPage.jsx create mode 100644 src/components/Dashboard/context/ActionsContext.jsx create mode 100644 src/routes/ModelRoutes.jsx create mode 100644 src/utils/modelActions.js 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) +}