farmcontrol-ui/src/components/Dashboard/context/ActionsModalContext.jsx

317 lines
8.6 KiB
JavaScript

import { Input, Flex, List, Typography, Modal, Tag } from 'antd'
import {
createContext,
useContext,
useEffect,
useMemo,
useRef,
useState
} from 'react'
import PropTypes from 'prop-types'
import { useLocation, useNavigate } from 'react-router-dom'
import { getModelByName } from '../../../database/ObjectModels'
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}` : '')
}
// Flatten nested actions (including children) into a single list
const flattenActions = (actions, parentLabel = '') => {
if (!Array.isArray(actions)) return []
const flat = []
actions.forEach((action) => {
if (!action || action.type === 'divider') {
return
}
const hasUrl = typeof action.url === 'function'
const hasChildren =
Array.isArray(action.children) && action.children.length > 0
const currentLabel = action.label || action.name || ''
const fullLabel = parentLabel
? `${parentLabel} / ${currentLabel}`
: currentLabel
// Only push actions that are actually runnable
if (hasUrl) {
flat.push({
...action,
key: action.key || action.name || fullLabel,
fullLabel
})
}
if (hasChildren) {
flat.push(...flattenActions(action.children, fullLabel))
}
})
return flat
}
const ActionsModalProvider = ({ children }) => {
const { Text } = Typography
const navigate = useNavigate()
const location = useLocation()
const [visible, setVisible] = useState(false)
const [query, setQuery] = useState('')
const [context, setContext] = useState({
id: null,
type: null,
objectData: null
})
const inputRef = useRef(null)
const showActionsModal = (id, type, objectData = null) => {
setContext({ id, type, objectData })
setQuery('')
setVisible(true)
}
const hideActionsModal = () => {
setVisible(false)
setQuery('')
}
// Focus and select text in input when modal becomes visible
useEffect(() => {
// Use a small timeout to ensure the modal is fully rendered and visible
setTimeout(() => {
if (visible) {
console.log('visible', visible)
console.log('inputRef.current', inputRef.current)
if (visible && inputRef.current) {
console.log('focusing input')
const input = inputRef.current.input
if (input) {
input.focus()
input.select() // Select all text
}
}
}
}, 50)
}, [visible])
const model = context.type ? getModelByName(context.type) : null
const ModelIcon = model?.icon || null
const modelLabel = model?.label || model?.name || ''
const flattenedActions = useMemo(
() => flattenActions(model?.actions || []),
[model]
)
const currentUrlWithoutActions = stripActionParam(
location.pathname,
location.search
)
const getActionDisabled = (action) => {
const { id, objectData } = context
if (!action) return true
let disabled = false
const url = action.url ? action.url(id) : undefined
// Match ObjectActions default disabling behaviour
if (url && url === currentUrlWithoutActions) {
disabled = true
}
if (typeof action.disabled !== 'undefined') {
if (typeof action.disabled === 'function') {
disabled = action.disabled(objectData)
} else {
disabled = action.disabled
}
}
return disabled
}
const getVisibleDisabled = (action) => {
const { objectData } = context
if (!action) return true
let visible = true
if (typeof action.visible !== 'undefined') {
if (typeof action.visible === 'function') {
visible = action.visible(objectData)
} else {
visible = action.visible
}
}
return visible
}
const normalizedQuery = query.trim().toLowerCase()
const filteredActions = flattenedActions.filter((action) => {
if (!normalizedQuery) return true
const haystack = [
action.fullLabel || '',
action.label || '',
action.name || '',
modelLabel
]
.join(' ')
.toLowerCase()
return haystack.includes(normalizedQuery)
})
const runAction = (action) => {
if (!action || typeof action.url !== 'function') return
if (getActionDisabled(action)) return
const { id } = context
const targetUrl = action.url(id)
if (targetUrl && targetUrl !== '#') {
navigate(targetUrl)
hideActionsModal()
}
}
const handleKeyDown = (e) => {
if (!filteredActions.length) return
// Enter triggers first visible action
if (e.key === 'Enter') {
e.preventDefault()
const first = filteredActions[0]
runAction(first)
return
}
// Number keys 1-9 trigger corresponding actions
if (/^[1-9]$/.test(e.key)) {
e.preventDefault()
const index = parseInt(e.key, 10)
if (index < filteredActions.length) {
const action = filteredActions[index]
runAction(action)
}
}
}
return (
<ActionsModalContext.Provider value={{ showActionsModal }}>
<Modal
open={visible}
onCancel={hideActionsModal}
closeIcon={null}
footer={null}
width={700}
styles={{ content: { padding: 0 } }}
destroyOnClose={true}
>
<Flex vertical>
<Input
ref={inputRef}
addonBefore={
<Text style={{ fontSize: '20px' }}>
<ModelIcon />
</Text>
}
placeholder='Search actions...'
size='large'
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
/>
{filteredActions.length > 0 && (
<div
style={{
marginLeft: '14px',
marginRight: '14px'
}}
>
<List
dataSource={filteredActions.filter((action) =>
getVisibleDisabled(action)
)}
renderItem={(action, index) => {
const Icon = action.icon
const disabled = getActionDisabled(action)
let shortcutText = ''
if (index === 0) {
shortcutText = 'ENTER'
} else if (index <= 9) {
shortcutText = index.toString()
}
return (
<List.Item
onClick={() => !disabled && runAction(action)}
style={{
cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.5 : 1
}}
>
<Flex
gap='middle'
style={{ width: '100%' }}
align='center'
justify='space-between'
>
<Flex
gap='small'
align='center'
style={{ flexGrow: 1, minWidth: 0 }}
>
{Icon ? <Icon style={{ fontSize: '18px' }} /> : null}
<Flex vertical style={{ flexGrow: 1, minWidth: 0 }}>
<Text
ellipsis
style={{
maxWidth: 320,
width: '100%'
}}
>
{action.fullLabel || action.label || action.name}
</Text>
</Flex>
</Flex>
<Flex gap='small' align='center'>
{action.danger && <Tag color='red'>Danger</Tag>}
{shortcutText && <Text keyboard>{shortcutText}</Text>}
</Flex>
</Flex>
</List.Item>
)
}}
/>
</div>
)}
</Flex>
</Modal>
{children}
</ActionsModalContext.Provider>
)
}
ActionsModalProvider.propTypes = {
children: PropTypes.node.isRequired
}
const useActionsModal = () => useContext(ActionsModalContext)
// eslint-disable-next-line react-refresh/only-export-components
export { ActionsModalProvider, ActionsModalContext, useActionsModal }