317 lines
8.6 KiB
JavaScript
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 }
|