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:
parent
c3cfd0a55e
commit
89b6f476c1
165
src/App.jsx
165
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 = () => {
|
||||
<PrintServerProvider>
|
||||
<ApiServerProvider>
|
||||
<MessageProvider>
|
||||
<AppUpdateProvider>
|
||||
<NotificationProvider>
|
||||
<SpotlightProvider>
|
||||
<ActionsModalProvider>
|
||||
<TableStateProvider>
|
||||
<Routes>
|
||||
<Route
|
||||
path='/applaunch'
|
||||
element={<AuthLaunch />}
|
||||
/>
|
||||
<Route
|
||||
path='/dashboard/electron/spotlightcontent'
|
||||
element={
|
||||
<PrivateRoute
|
||||
component={() => (
|
||||
<ElectronSpotlightContentPage />
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/'
|
||||
element={
|
||||
<PrivateRoute
|
||||
component={() => (
|
||||
<Navigate
|
||||
to='/dashboard/production/overview'
|
||||
replace
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/auth/callback'
|
||||
element={<AuthCallback />}
|
||||
/>
|
||||
<Route
|
||||
path='/auth/marketplace/callback'
|
||||
element={<MarketplaceAuthCallback />}
|
||||
/>
|
||||
<Route
|
||||
path='/email/notification'
|
||||
element={<EmailNotificationTemplate />}
|
||||
/>
|
||||
<Route
|
||||
path='/slicer'
|
||||
element={<SlicerIntegration />}
|
||||
/>
|
||||
<TableStateProvider>
|
||||
<AppUpdateProvider>
|
||||
<NotificationProvider>
|
||||
<SpotlightProvider>
|
||||
<ActionsModalProvider>
|
||||
<ActionsProvider>
|
||||
<Routes>
|
||||
<Route
|
||||
path='/applaunch'
|
||||
element={<AuthLaunch />}
|
||||
/>
|
||||
<Route
|
||||
path='/dashboard/electron/spotlightcontent'
|
||||
element={
|
||||
<PrivateRoute
|
||||
component={() => (
|
||||
<ElectronSpotlightContentPage />
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/'
|
||||
element={
|
||||
<PrivateRoute
|
||||
component={() => (
|
||||
<Navigate
|
||||
to='/dashboard/production/overview'
|
||||
replace
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/auth/callback'
|
||||
element={<AuthCallback />}
|
||||
/>
|
||||
<Route
|
||||
path='/auth/marketplace/callback'
|
||||
element={<MarketplaceAuthCallback />}
|
||||
/>
|
||||
<Route
|
||||
path='/email/notification'
|
||||
element={<EmailNotificationTemplate />}
|
||||
/>
|
||||
<Route
|
||||
path='/slicer'
|
||||
element={<SlicerIntegration />}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path='/dashboard'
|
||||
element={
|
||||
<PrivateRoute
|
||||
component={() => <Dashboard />}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{ProductionRoutes}
|
||||
{InventoryRoutes}
|
||||
{FinanceRoutes}
|
||||
{SalesRoutes}
|
||||
{ManagementRoutes}
|
||||
{DeveloperRoutes}
|
||||
</Route>
|
||||
<Route
|
||||
path='*'
|
||||
element={
|
||||
<AppError
|
||||
message='Error 404. Page not found.'
|
||||
showRefresh={false}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</TableStateProvider>
|
||||
</ActionsModalProvider>
|
||||
</SpotlightProvider>
|
||||
</NotificationProvider>
|
||||
</AppUpdateProvider>
|
||||
<Route
|
||||
path='/dashboard'
|
||||
element={
|
||||
<PrivateRoute
|
||||
component={() => <Dashboard />}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{ProductionRoutes}
|
||||
{InventoryRoutes}
|
||||
{FinanceRoutes}
|
||||
{SalesRoutes}
|
||||
{ManagementRoutes}
|
||||
{ModelRoutes}
|
||||
{DeveloperRoutes}
|
||||
</Route>
|
||||
<Route
|
||||
path='*'
|
||||
element={
|
||||
<AppError
|
||||
message='Error 404. Page not found.'
|
||||
showRefresh={false}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</ActionsProvider>
|
||||
</ActionsModalProvider>
|
||||
</SpotlightProvider>
|
||||
</NotificationProvider>
|
||||
</AppUpdateProvider>
|
||||
</TableStateProvider>
|
||||
</MessageProvider>
|
||||
</ApiServerProvider>
|
||||
</PrintServerProvider>
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
56
src/components/Dashboard/common/ModelPage.jsx
Normal file
56
src/components/Dashboard/common/ModelPage.jsx
Normal 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
|
||||
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
@ -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?.()
|
||||
|
||||
222
src/components/Dashboard/context/ActionsContext.jsx
Normal file
222
src/components/Dashboard/context/ActionsContext.jsx
Normal 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 }
|
||||
@ -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()
|
||||
|
||||
@ -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 && <Text keyboard>{shortcutText}</Text>}
|
||||
|
||||
20
src/routes/ModelRoutes.jsx
Normal file
20
src/routes/ModelRoutes.jsx
Normal 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
|
||||
@ -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'
|
||||
|
||||
72
src/utils/modelActions.js
Normal file
72
src/utils/modelActions.js
Normal 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)
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user