360 lines
12 KiB
TypeScript
360 lines
12 KiB
TypeScript
import React, { 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 [modalMode, setModalMode] = useState<'create' | 'edit'>('create')
|
||
|
||
const getCreateInitialValues = (): Partial<MenuDto> => ({
|
||
code: '',
|
||
displayName: '',
|
||
order: (item.children?.length || 0) + 1,
|
||
parentCode: item.code,
|
||
url: '',
|
||
icon: '',
|
||
cssClass: '',
|
||
requiredPermissionName: '',
|
||
target: '',
|
||
isDisabled: false,
|
||
elementId: '',
|
||
})
|
||
|
||
const getEditInitialValues = (): Partial<MenuDto> => ({
|
||
id: item.id,
|
||
code: item.code || '',
|
||
displayName: item.displayName || '',
|
||
order: item.order,
|
||
parentCode: item.parentCode || '',
|
||
url: item.url || '',
|
||
icon: item.icon || '',
|
||
cssClass: item.cssClass || '',
|
||
requiredPermissionName: item.requiredPermissionName || '',
|
||
target: item.target || '',
|
||
isDisabled: item.isDisabled ?? false,
|
||
elementId: item.elementId || '',
|
||
})
|
||
|
||
const [formData, setFormData] = useState<Partial<MenuDto>>(getCreateInitialValues())
|
||
|
||
const toggleExpanded = () => {
|
||
if (!isDesignMode) setIsExpanded(!isExpanded)
|
||
}
|
||
|
||
const openCreateModal = () => {
|
||
setModalMode('create')
|
||
setFormData(getCreateInitialValues())
|
||
setIsModalOpen(true)
|
||
}
|
||
|
||
const openEditModal = (event: React.MouseEvent) => {
|
||
if (!isDesignMode) return
|
||
event.stopPropagation()
|
||
setModalMode('edit')
|
||
setFormData(getEditInitialValues())
|
||
setIsModalOpen(true)
|
||
}
|
||
|
||
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={openCreateModal} 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>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={openEditModal}
|
||
className={`
|
||
truncate text-gray-800 leading-6 text-sm text-left
|
||
${item.children && item.children.length > 0 ? 'font-semibold' : 'font-normal'}
|
||
${isDesignMode ? 'hover:text-blue-600' : ''}
|
||
`}
|
||
>
|
||
{translate('::' + item.displayName)}
|
||
</button>
|
||
|
||
{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">
|
||
{modalMode === 'edit' ? translate('::Edit Menu Item') : translate('::New Item')}
|
||
</h5>
|
||
<Formik
|
||
validationSchema={validationSchema}
|
||
initialValues={formData}
|
||
enableReinitialize
|
||
onSubmit={async (values, { setSubmitting }) => {
|
||
try {
|
||
const menuService = new MenuService()
|
||
if (modalMode === 'edit' && item.id) {
|
||
await menuService.update(item.id, values as MenuDto)
|
||
} else {
|
||
await menuService.create(values as MenuDto)
|
||
}
|
||
toast.push(
|
||
<Notification title="Başarılı" type="success">
|
||
{modalMode === 'edit' ? translate('::KayitGuncellendi') : 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, isSubmitting }) => (
|
||
<Form>
|
||
<FormContainer>
|
||
<FormItem label="Code *" className="mb-2">
|
||
<Field
|
||
type="text"
|
||
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={values.parentCode || ''}
|
||
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}>
|
||
{modalMode === 'edit' ? translate('::Save') : translate('::Create')}
|
||
</Button>
|
||
</div>
|
||
</FormContainer>
|
||
</Form>
|
||
)}
|
||
</Formik>
|
||
</Dialog>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|