323 lines
11 KiB
TypeScript
323 lines
11 KiB
TypeScript
|
|
import React, { useEffect, useState } from 'react'
|
|||
|
|
import { useSortable } from '@dnd-kit/sortable'
|
|||
|
|
import { CSS } from '@dnd-kit/utilities'
|
|||
|
|
import { MenuItem } from '@/proxy/menus/menu'
|
|||
|
|
import { useLocalization } from '@/utils/hooks/useLocalization'
|
|||
|
|
import { MenuService } from '@/services/menu.service'
|
|||
|
|
import navigationIcon from '@/proxy/menus/navigation-icon.config'
|
|||
|
|
import { FaQuestionCircle } from 'react-icons/fa'
|
|||
|
|
import {
|
|||
|
|
Button,
|
|||
|
|
Dialog,
|
|||
|
|
FormContainer,
|
|||
|
|
FormItem,
|
|||
|
|
Input,
|
|||
|
|
Switcher,
|
|||
|
|
Notification,
|
|||
|
|
toast,
|
|||
|
|
Select,
|
|||
|
|
} from '@/components/ui'
|
|||
|
|
import { Field, FieldProps, Form, Formik } from 'formik'
|
|||
|
|
import { SelectBoxOption } from '@/types/shared'
|
|||
|
|
import * as Yup from 'yup'
|
|||
|
|
import { FaExternalLinkAlt, FaPlus, FaTrashAlt } from 'react-icons/fa'
|
|||
|
|
import { MenuDto } from '@/proxy/menus/models'
|
|||
|
|
|
|||
|
|
interface MenuItemComponentProps {
|
|||
|
|
item: MenuItem
|
|||
|
|
isDesignMode: boolean
|
|||
|
|
depth: number
|
|||
|
|
children?: React.ReactNode
|
|||
|
|
isDragOverlay?: boolean
|
|||
|
|
refetch: () => void
|
|||
|
|
permissions: SelectBoxOption[]
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export const MenuItemComponent: React.FC<MenuItemComponentProps> = ({
|
|||
|
|
item,
|
|||
|
|
isDesignMode,
|
|||
|
|
depth,
|
|||
|
|
children,
|
|||
|
|
isDragOverlay = false,
|
|||
|
|
refetch,
|
|||
|
|
permissions,
|
|||
|
|
}) => {
|
|||
|
|
const { translate } = useLocalization()
|
|||
|
|
|
|||
|
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
|||
|
|
id: item.id || '',
|
|||
|
|
data: {
|
|||
|
|
type: 'menu-item',
|
|||
|
|
item,
|
|||
|
|
},
|
|||
|
|
disabled: !isDesignMode,
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const validationSchema = Yup.object().shape({
|
|||
|
|
code: Yup.string().required('Code is required'),
|
|||
|
|
displayName: Yup.string().required('Display Name is required'),
|
|||
|
|
order: Yup.number().typeError('Order must be a number').required('Order is required'),
|
|||
|
|
url: Yup.string().nullable(),
|
|||
|
|
icon: Yup.string().nullable(),
|
|||
|
|
cssClass: Yup.string().nullable(),
|
|||
|
|
requiredPermissionName: Yup.string().nullable(),
|
|||
|
|
target: Yup.string().nullable(),
|
|||
|
|
elementId: Yup.string().nullable(),
|
|||
|
|
isDisabled: Yup.boolean(),
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const style = {
|
|||
|
|
transform: CSS.Transform.toString(transform),
|
|||
|
|
transition,
|
|||
|
|
opacity: isDragging ? 0.5 : 1,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const [isExpanded, setIsExpanded] = useState(true)
|
|||
|
|
const [isModalOpen, setIsModalOpen] = useState(false)
|
|||
|
|
const [formData, setFormData] = useState<Partial<MenuDto>>({
|
|||
|
|
code: '',
|
|||
|
|
displayName: '',
|
|||
|
|
order: (item.children?.length || 0) + 1,
|
|||
|
|
parentCode: item.code,
|
|||
|
|
url: '',
|
|||
|
|
icon: '',
|
|||
|
|
cssClass: '',
|
|||
|
|
requiredPermissionName: '',
|
|||
|
|
target: '',
|
|||
|
|
isDisabled: false,
|
|||
|
|
elementId: '',
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const toggleExpanded = () => {
|
|||
|
|
if (!isDesignMode) setIsExpanded(!isExpanded)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const handleCreate = async () => {
|
|||
|
|
const menuService = new MenuService()
|
|||
|
|
await menuService.create(formData as MenuDto)
|
|||
|
|
setIsModalOpen(false)
|
|||
|
|
refetch()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const handleDelete = async () => {
|
|||
|
|
const confirmed = window.confirm(`Delete "${item.displayName}"?`)
|
|||
|
|
if (!confirmed) return
|
|||
|
|
const menuService = new MenuService()
|
|||
|
|
await menuService.delete(item.id!)
|
|||
|
|
refetch()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="select-none">
|
|||
|
|
<div
|
|||
|
|
ref={setNodeRef}
|
|||
|
|
style={style}
|
|||
|
|
className={`
|
|||
|
|
flex items-center gap-1 p-1 rounded-lg transition-all duration-200 group min-h-[30px]
|
|||
|
|
${isDesignMode ? 'cursor-move hover:bg-blue-50 border border-transparent hover:border-blue-200' : 'cursor-pointer hover:bg-gray-50'}
|
|||
|
|
${isDragOverlay ? 'shadow-lg bg-white border border-blue-300 z-50' : ''}
|
|||
|
|
${isDragging ? 'opacity-50' : ''}
|
|||
|
|
${item.children && item.children.length > 0 ? 'bg-blue-50' : depth === 0 ? 'bg-white' : 'bg-gray-50'}
|
|||
|
|
`}
|
|||
|
|
{...(isDesignMode ? { ...attributes, ...listeners } : { onClick: toggleExpanded })}
|
|||
|
|
>
|
|||
|
|
{isDesignMode && (
|
|||
|
|
<div className="flex gap-2 items-center mr-2">
|
|||
|
|
<button onClick={() => setIsModalOpen(true)} title="New Item">
|
|||
|
|
<FaPlus size={16} className="text-green-600 hover:text-green-800" />
|
|||
|
|
</button>
|
|||
|
|
<button onClick={handleDelete} title="Delete Item">
|
|||
|
|
<FaTrashAlt size={16} className="text-red-600 hover:text-red-800" />
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
|||
|
|
<div className="flex-shrink-0 text-gray-600 text-xl">
|
|||
|
|
{navigationIcon[item.icon || ''] ? (
|
|||
|
|
React.createElement(navigationIcon[item.icon || ''], { className: 'text-gray-400' })
|
|||
|
|
) : (
|
|||
|
|
<FaQuestionCircle className="text-gray-400" />
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<span
|
|||
|
|
className={`
|
|||
|
|
truncate text-gray-800 leading-6 text-sm
|
|||
|
|
${item.children && item.children.length > 0 ? 'font-semibold' : 'font-normal'}
|
|||
|
|
`}
|
|||
|
|
>
|
|||
|
|
{translate('::' + item.displayName)}
|
|||
|
|
</span>
|
|||
|
|
|
|||
|
|
{item.url && <FaExternalLinkAlt size={12} className="flex-shrink-0 text-gray-400" />}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="flex items-center gap-2 flex-shrink-0">
|
|||
|
|
{isDesignMode && (
|
|||
|
|
<div className="flex items-center gap-2 text-xs text-gray-500">
|
|||
|
|
<span className="bg-gray-200 px-2 py-1 rounded">#{item.order}</span>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{item.children && item.children.length > 0 && (
|
|||
|
|
<span className="text-xs text-gray-500 bg-blue-100 px-2 py-1 rounded-full">
|
|||
|
|
{item.children.length}
|
|||
|
|
</span>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{children && (!isDesignMode ? isExpanded : true) && <div className="mt-1">{children}</div>}
|
|||
|
|
|
|||
|
|
{isModalOpen && (
|
|||
|
|
<Dialog
|
|||
|
|
isOpen={isModalOpen}
|
|||
|
|
onClose={() => setIsModalOpen(false)}
|
|||
|
|
onRequestClose={() => setIsModalOpen(false)}
|
|||
|
|
width={600}
|
|||
|
|
>
|
|||
|
|
<h5 className="mb-4">{translate('::New Item')}</h5>
|
|||
|
|
<Formik
|
|||
|
|
validationSchema={validationSchema}
|
|||
|
|
initialValues={formData}
|
|||
|
|
enableReinitialize
|
|||
|
|
onSubmit={async (values, { setSubmitting }) => {
|
|||
|
|
try {
|
|||
|
|
const menuService = new MenuService()
|
|||
|
|
await menuService.create(values as MenuDto)
|
|||
|
|
toast.push(
|
|||
|
|
<Notification title="Başarılı" type="success">
|
|||
|
|
{translate('::KayitEklendi')}
|
|||
|
|
</Notification>,
|
|||
|
|
{ placement: 'top-end' },
|
|||
|
|
)
|
|||
|
|
setIsModalOpen(false)
|
|||
|
|
refetch()
|
|||
|
|
} catch (error) {
|
|||
|
|
toast.push(
|
|||
|
|
<Notification title="Hata" type="danger">
|
|||
|
|
{translate('::IslemBasarisiz')}
|
|||
|
|
</Notification>,
|
|||
|
|
{ placement: 'top-end' },
|
|||
|
|
)
|
|||
|
|
} finally {
|
|||
|
|
setSubmitting(false)
|
|||
|
|
}
|
|||
|
|
}}
|
|||
|
|
>
|
|||
|
|
{({ values, errors, touched, isSubmitting, handleChange }) => (
|
|||
|
|
<Form>
|
|||
|
|
<FormContainer>
|
|||
|
|
<FormItem label="Code *" className="mb-2">
|
|||
|
|
<Field
|
|||
|
|
type="text"
|
|||
|
|
au
|
|||
|
|
name="code"
|
|||
|
|
component={Input}
|
|||
|
|
className="h-8 text-sm px-2"
|
|||
|
|
autoFocus
|
|||
|
|
/>
|
|||
|
|
</FormItem>
|
|||
|
|
|
|||
|
|
<FormItem label="Display Name *" className="mb-2">
|
|||
|
|
<Field
|
|||
|
|
type="text"
|
|||
|
|
name="displayName"
|
|||
|
|
component={Input}
|
|||
|
|
className="h-8 text-sm px-2"
|
|||
|
|
/>
|
|||
|
|
</FormItem>
|
|||
|
|
|
|||
|
|
<FormItem label="Order *" className="mb-2">
|
|||
|
|
<Field
|
|||
|
|
type="number"
|
|||
|
|
name="order"
|
|||
|
|
component={Input}
|
|||
|
|
className="h-8 text-sm px-2"
|
|||
|
|
/>
|
|||
|
|
</FormItem>
|
|||
|
|
|
|||
|
|
<FormItem label="URL" className="mb-2">
|
|||
|
|
<Field type="text" name="url" component={Input} className="h-8 text-sm px-2" />
|
|||
|
|
</FormItem>
|
|||
|
|
|
|||
|
|
<FormItem label="Icon" className="mb-2">
|
|||
|
|
<Field type="text" name="icon" component={Input} className="h-8 text-sm px-2" />
|
|||
|
|
</FormItem>
|
|||
|
|
|
|||
|
|
<FormItem label="Parent Code" className="mb-2">
|
|||
|
|
<Input disabled value={item.code} className="h-8 text-sm px-2 bg-gray-100" />
|
|||
|
|
</FormItem>
|
|||
|
|
|
|||
|
|
<FormItem label="CSS Class" className="mb-2">
|
|||
|
|
<Field
|
|||
|
|
type="text"
|
|||
|
|
name="cssClass"
|
|||
|
|
component={Input}
|
|||
|
|
className="h-8 text-sm px-2"
|
|||
|
|
/>
|
|||
|
|
</FormItem>
|
|||
|
|
|
|||
|
|
<FormItem label="Permission Name" className="mb-2">
|
|||
|
|
<Field
|
|||
|
|
type="text"
|
|||
|
|
autoComplete="off"
|
|||
|
|
name="requiredPermissionName"
|
|||
|
|
className="h-8 text-sm px-2"
|
|||
|
|
>
|
|||
|
|
{({ field, form }: FieldProps<SelectBoxOption>) => (
|
|||
|
|
<Select
|
|||
|
|
field={field}
|
|||
|
|
form={form}
|
|||
|
|
isClearable={true}
|
|||
|
|
options={permissions}
|
|||
|
|
value={permissions?.filter(
|
|||
|
|
(option) => option.value === values.requiredPermissionName,
|
|||
|
|
)}
|
|||
|
|
onChange={(option) => form.setFieldValue(field.name, option?.value)}
|
|||
|
|
/>
|
|||
|
|
)}
|
|||
|
|
</Field>
|
|||
|
|
</FormItem>
|
|||
|
|
|
|||
|
|
<FormItem label="Target" className="mb-2">
|
|||
|
|
<Field
|
|||
|
|
type="text"
|
|||
|
|
name="target"
|
|||
|
|
component={Input}
|
|||
|
|
className="h-8 text-sm px-2"
|
|||
|
|
/>
|
|||
|
|
</FormItem>
|
|||
|
|
|
|||
|
|
<FormItem label="Element Id" className="mb-2">
|
|||
|
|
<Field
|
|||
|
|
type="text"
|
|||
|
|
name="elementId"
|
|||
|
|
component={Input}
|
|||
|
|
className="h-8 text-sm px-2"
|
|||
|
|
/>
|
|||
|
|
</FormItem>
|
|||
|
|
|
|||
|
|
<FormItem label="Is Disabled" className="mb-2">
|
|||
|
|
<Field name="isDisabled" component={Switcher} />
|
|||
|
|
</FormItem>
|
|||
|
|
|
|||
|
|
<div className="flex justify-end gap-2 mt-4">
|
|||
|
|
<Button variant="plain" size="sm" onClick={() => setIsModalOpen(false)}>
|
|||
|
|
{translate('::Cancel')}
|
|||
|
|
</Button>
|
|||
|
|
<Button type="submit" variant="solid" size="sm" loading={isSubmitting}>
|
|||
|
|
{translate('::Create')}
|
|||
|
|
</Button>
|
|||
|
|
</div>
|
|||
|
|
</FormContainer>
|
|||
|
|
</Form>
|
|||
|
|
)}
|
|||
|
|
</Formik>
|
|||
|
|
</Dialog>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|