menu manage düzenlemeleri

This commit is contained in:
Sedat Öztürk 2025-06-26 23:33:42 +03:00
parent 35e4957cc3
commit 51b167cc6b
5 changed files with 371 additions and 157 deletions

View file

@ -82,7 +82,7 @@ define(['./workbox-54d0af47'], (function (workbox) { 'use strict';
"revision": "3ca0b8505b4bec776b69afdba2768812" "revision": "3ca0b8505b4bec776b69afdba2768812"
}, { }, {
"url": "index.html", "url": "index.html",
"revision": "0.hld5cocdcl" "revision": "0.uun45k3p9s"
}], {}); }], {});
workbox.cleanupOutdatedCaches(); workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), { workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {

46
ui/package-lock.json generated
View file

@ -3742,11 +3742,10 @@
} }
}, },
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "2.0.1", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"balanced-match": "^1.0.0" "balanced-match": "^1.0.0"
} }
@ -4370,9 +4369,9 @@
"dev": true "dev": true
}, },
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "1.1.11", "version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dependencies": { "dependencies": {
"balanced-match": "^1.0.0", "balanced-match": "^1.0.0",
"concat-map": "0.0.1" "concat-map": "0.0.1"
@ -6171,9 +6170,9 @@
} }
}, },
"node_modules/eslint-plugin-n/node_modules/brace-expansion": { "node_modules/eslint-plugin-n/node_modules/brace-expansion": {
"version": "2.0.1", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"balanced-match": "^1.0.0" "balanced-match": "^1.0.0"
@ -6642,9 +6641,9 @@
} }
}, },
"node_modules/filelist/node_modules/brace-expansion": { "node_modules/filelist/node_modules/brace-expansion": {
"version": "2.0.1", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"balanced-match": "^1.0.0" "balanced-match": "^1.0.0"
@ -10421,10 +10420,9 @@
} }
}, },
"node_modules/readdir-glob/node_modules/brace-expansion": { "node_modules/readdir-glob/node_modules/brace-expansion": {
"version": "2.0.1", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"license": "MIT",
"dependencies": { "dependencies": {
"balanced-match": "^1.0.0" "balanced-match": "^1.0.0"
} }
@ -11991,10 +11989,9 @@
} }
}, },
"node_modules/sucrase/node_modules/brace-expansion": { "node_modules/sucrase/node_modules/brace-expansion": {
"version": "2.0.1", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"license": "MIT",
"dependencies": { "dependencies": {
"balanced-match": "^1.0.0" "balanced-match": "^1.0.0"
} }
@ -12825,11 +12822,10 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "5.4.15", "version": "5.4.19",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.15.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.19.tgz",
"integrity": "sha512-6ANcZRivqL/4WtwPGTKNaosuNJr5tWiftOC7liM7G9+rMb8+oeJeyzymDu4rTN93seySBmbjSfsS3Vzr19KNtA==", "integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"esbuild": "^0.21.3", "esbuild": "^0.21.3",
"postcss": "^8.4.43", "postcss": "^8.4.43",

View file

@ -1,9 +1,29 @@
import React from 'react' import React, { useEffect, useState } from 'react'
import { useSortable } from '@dnd-kit/sortable' import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities' import { CSS } from '@dnd-kit/utilities'
import * as Icons from 'lucide-react' import * as Icons from 'lucide-react'
import { MenuItem } from '@/@types/menu' import { MenuItem } from '@/@types/menu'
import { useLocalization } from '@/utils/hooks/useLocalization' import { useLocalization } from '@/utils/hooks/useLocalization'
import { MenuService } from '@/services/menu.service'
import { MenuDto } from '@/proxy/menus'
import navigationIcon from '@/configs/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 '@/shared/types'
import { getPermissionsList } from '@/proxy/admin/identity.service'
import { PermissionDefinitionRecord } from '@/proxy/admin'
import * as Yup from 'yup'
interface MenuItemComponentProps { interface MenuItemComponentProps {
item: MenuItem item: MenuItem
@ -11,6 +31,7 @@ interface MenuItemComponentProps {
depth: number depth: number
children?: React.ReactNode children?: React.ReactNode
isDragOverlay?: boolean isDragOverlay?: boolean
refetch: () => void
} }
export const MenuItemComponent: React.FC<MenuItemComponentProps> = ({ export const MenuItemComponent: React.FC<MenuItemComponentProps> = ({
@ -19,7 +40,9 @@ export const MenuItemComponent: React.FC<MenuItemComponentProps> = ({
depth, depth,
children, children,
isDragOverlay = false, isDragOverlay = false,
refetch,
}) => { }) => {
const [permissions, setPermissions] = useState<SelectBoxOption[]>([])
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: item.id || '', id: item.id || '',
data: { data: {
@ -29,8 +52,20 @@ export const MenuItemComponent: React.FC<MenuItemComponentProps> = ({
disabled: !isDesignMode, disabled: !isDesignMode,
}) })
const { translate } = useLocalization() 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 { translate } = useLocalization()
const style = { const style = {
transform: CSS.Transform.toString(transform), transform: CSS.Transform.toString(transform),
transition, transition,
@ -42,14 +77,59 @@ export const MenuItemComponent: React.FC<MenuItemComponentProps> = ({
return IconComponent ? <IconComponent size={16} /> : <Icons.FileText size={16} /> return IconComponent ? <IconComponent size={16} /> : <Icons.FileText size={16} />
} }
const [isExpanded, setIsExpanded] = React.useState(true) 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 = () => { const toggleExpanded = () => {
if (!isDesignMode) { if (!isDesignMode) setIsExpanded(!isExpanded)
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()
}
const getPermissionList = async () => {
const response = await getPermissionsList()
if (response.data) {
setPermissions(
response.data.map((permission: PermissionDefinitionRecord) => ({
value: permission.name,
label: permission.name,
})),
)
} }
} }
useEffect(() => {
if (permissions) {
getPermissionList()
}
}, [permissions])
return ( return (
<div className="select-none"> <div className="select-none">
<div <div
@ -64,13 +144,26 @@ export const MenuItemComponent: React.FC<MenuItemComponentProps> = ({
`} `}
{...(isDesignMode ? { ...attributes, ...listeners } : { onClick: toggleExpanded })} {...(isDesignMode ? { ...attributes, ...listeners } : { onClick: toggleExpanded })}
> >
{isDesignMode && (
<div className="flex gap-2 items-center mr-2">
<button onClick={() => setIsModalOpen(true)} title="New Item">
<Icons.Plus size={16} className="text-green-600 hover:text-green-800" />
</button>
<button onClick={handleDelete} title="Delete Item">
<Icons.Trash2 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 items-center gap-3 flex-1 min-w-0">
<div className="flex-shrink-0 text-gray-600">{item.icon && getIcon(item.icon)}</div> <div className="flex-shrink-0 text-gray-600 text-xl">
{navigationIcon[item.icon || ''] ?? <FaQuestionCircle className="text-gray-400" />}
</div>
<span <span
className={` className={`
truncate text-gray-800 leading-6 text-sm truncate text-gray-800 leading-6 text-sm
${isDesignMode ? 'font-normal' : item.children && item.children.length > 0 ? 'font-semibold' : 'font-normal'} ${item.children && item.children.length > 0 ? 'font-semibold' : 'font-normal'}
`} `}
> >
{translate('::' + item.displayName)} {translate('::' + item.displayName)}
@ -95,6 +188,147 @@ export const MenuItemComponent: React.FC<MenuItemComponentProps> = ({
</div> </div>
{children && (!isDesignMode ? isExpanded : true) && <div className="mt-1">{children}</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-center' },
)
setIsModalOpen(false)
refetch()
} catch (error) {
toast.push(
<Notification title="Hata" type="danger">
{translate('::IslemBasarisiz')}
</Notification>,
{ placement: 'top-center' },
)
} 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> </div>
) )
} }

View file

@ -6,7 +6,7 @@ import { useMenuData } from '@/utils/hooks/useMenuData'
export const MenuManager = () => { export const MenuManager = () => {
const { menuItems, setMenuItems, loading, error, refetch, saveMenuData } = useMenuData() const { menuItems, setMenuItems, loading, error, refetch, saveMenuData } = useMenuData()
const [isDesignMode, setIsDesignMode] = useState(false) const [isDesignMode, setIsDesignMode] = useState(true)
const [isSaving, setIsSaving] = useState(false) const [isSaving, setIsSaving] = useState(false)
const [saveMessage, setSaveMessage] = useState<{ const [saveMessage, setSaveMessage] = useState<{
type: 'success' | 'error' type: 'success' | 'error'
@ -27,6 +27,7 @@ export const MenuManager = () => {
await saveMenuData(menuItems) await saveMenuData(menuItems)
setSaveMessage({ type: 'success', text: 'Menu configuration saved successfully!' }) setSaveMessage({ type: 'success', text: 'Menu configuration saved successfully!' })
setTimeout(() => setSaveMessage(null), 3000) setTimeout(() => setSaveMessage(null), 3000)
setIsDesignMode(false)
} catch (err) { } catch (err) {
setSaveMessage({ setSaveMessage({
type: 'error', type: 'error',
@ -75,82 +76,81 @@ export const MenuManager = () => {
} }
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="w-full h-full">
<div className="w-full h-full p-6"> {/* Menu Tree */}
{/* Menu Tree */} <div className="bg-white rounded-lg shadow-md p-6">
<div className="bg-white rounded-lg shadow-md p-6"> <div className="flex items-center justify-between mb-6 flex-wrap gap-4">
<div className="flex items-center justify-between mb-6 flex-wrap gap-4"> {/* Sol kısım: Başlık */}
{/* Sol kısım: Başlık */} <div className="flex items-center gap-2">
<div className="flex items-center gap-2"> <Icons.Menu size={20} className="text-gray-600" />
<Icons.Menu size={20} className="text-gray-600" /> <h2 className="text-lg font-semibold text-gray-900">Menu Manager</h2>
<h2 className="text-lg font-semibold text-gray-900">Menu Manager</h2> <span className="text-sm text-gray-500">({menuItems.length} root items)</span>
<span className="text-sm text-gray-500">({menuItems.length} root items)</span> </div>
</div>
{/* Sağ kısım: Design Mode + Save butonu */} {/* Sağ kısım: Design Mode + Save butonu */}
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span <span
className={`text-sm font-medium ${isDesignMode ? 'text-blue-600' : 'text-gray-500'}`} className={`text-sm font-medium ${isDesignMode ? 'text-blue-600' : 'text-gray-500'}`}
> >
Design Mode Design Mode
</span> </span>
<button <button
onClick={handleToggleDesignMode} onClick={handleToggleDesignMode}
className={` className={`
relative inline-flex h-6 w-11 items-center rounded-full transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 relative inline-flex h-6 w-11 items-center rounded-full transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2
${isDesignMode ? 'bg-blue-600' : 'bg-gray-200'} ${isDesignMode ? 'bg-blue-600' : 'bg-gray-200'}
`} `}
> >
<span <span
className={` className={`
inline-block h-4 w-4 transform rounded-full bg-white transition-transform duration-200 ease-in-out inline-block h-4 w-4 transform rounded-full bg-white transition-transform duration-200 ease-in-out
${isDesignMode ? 'translate-x-6' : 'translate-x-1'} ${isDesignMode ? 'translate-x-6' : 'translate-x-1'}
`} `}
/> />
</button> </button>
</div> </div>
{ {
<button <button
onClick={handleSave} onClick={handleSave}
disabled={!isDesignMode || isSaving} disabled={!isDesignMode || isSaving}
className={` className={`
flex items-center gap-2 px-4 py-2 rounded-lg transition-colors flex items-center gap-2 px-4 py-2 rounded-lg transition-colors
${isDesignMode ? 'bg-green-600 hover:bg-green-700 text-white' : 'bg-gray-300 text-gray-500 cursor-not-allowed'} ${isDesignMode ? 'bg-green-600 hover:bg-green-700 text-white' : 'bg-gray-300 text-gray-500 cursor-not-allowed'}
${isSaving ? 'opacity-50' : ''} ${isSaving ? 'opacity-50' : ''}
`} `}
> >
{isSaving ? ( {isSaving ? (
<> <>
<Icons.Loader2 size={16} className="animate-spin" /> <Icons.Loader2 size={16} className="animate-spin" />
Saving... Saving...
</> </>
) : ( ) : (
<> <>
<Icons.Save size={16} /> <Icons.Save size={16} />
Save Changes Save Changes
</> </>
)} )}
</button> </button>
} }
</div>
</div> </div>
{menuItems.length > 0 ? (
<SortableMenuTree
items={menuItems}
onItemsChange={handleMenuChange}
isDesignMode={isDesignMode}
/>
) : (
<div className="text-center py-12 text-gray-500">
<Icons.Menu size={24} className="mx-auto mb-4 text-gray-300" />
<p className="text-lg">No menu items found</p>
<p className="text-sm">Try refreshing the page or contact your administrator</p>
</div>
)}
</div> </div>
{menuItems.length > 0 ? (
<SortableMenuTree
items={menuItems}
onItemsChange={handleMenuChange}
isDesignMode={isDesignMode}
refetch={refetch}
/>
) : (
<div className="text-center py-12 text-gray-500">
<Icons.Menu size={24} className="mx-auto mb-4 text-gray-300" />
<p className="text-lg">No menu items found</p>
<p className="text-sm">Try refreshing the page or contact your administrator</p>
</div>
)}
</div> </div>
</div> </div>
) )

View file

@ -22,12 +22,14 @@ interface SortableMenuTreeProps {
items: MenuItem[] items: MenuItem[]
onItemsChange: (items: MenuItem[]) => void onItemsChange: (items: MenuItem[]) => void
isDesignMode: boolean isDesignMode: boolean
refetch: () => void
} }
export const SortableMenuTree: React.FC<SortableMenuTreeProps> = ({ export const SortableMenuTree: React.FC<SortableMenuTreeProps> = ({
items, items,
onItemsChange, onItemsChange,
isDesignMode, isDesignMode,
refetch,
}) => { }) => {
const [activeItem, setActiveItem] = React.useState<MenuItem | null>(null) const [activeItem, setActiveItem] = React.useState<MenuItem | null>(null)
@ -42,31 +44,25 @@ export const SortableMenuTree: React.FC<SortableMenuTreeProps> = ({
}), }),
) )
// Flatten the tree structure to get all items with their paths
const flattenItems = ( const flattenItems = (
items: MenuItem[], items: MenuItem[],
parentPath: string[] = [], parentPath: number[] = [],
): Array<{ item: MenuItem; path: number[] }> => { ): Array<{ item: MenuItem; path: number[] }> => {
const result: Array<{ item: MenuItem; path: number[] }> = [] return items.flatMap((item, index) => {
const path = [...parentPath, index]
const self = { item, path }
items.forEach((item, index) => { if (item.children?.length) {
const currentPath = [...parentPath, index] return [self, ...flattenItems(item.children, path)]
result.push({ item, path: currentPath })
if (item.children && item.children.length > 0) {
result.push(...flattenItems(item.children, currentPath))
} }
})
return result return [self]
})
} }
// Find item by ID in the tree
const findItemById = (items: MenuItem[], id: string): MenuItem | null => { const findItemById = (items: MenuItem[], id: string): MenuItem | null => {
for (const item of items) { for (const item of items) {
if (item.id === id) { if (item.id === id) return item
return item
}
if (item.children) { if (item.children) {
const found = findItemById(item.children, id) const found = findItemById(item.children, id)
if (found) return found if (found) return found
@ -75,12 +71,9 @@ export const SortableMenuTree: React.FC<SortableMenuTreeProps> = ({
return null return null
} }
// Remove item from tree by ID
const removeItemFromTree = (items: MenuItem[], id: string): MenuItem[] => { const removeItemFromTree = (items: MenuItem[], id: string): MenuItem[] => {
return items.reduce((acc: MenuItem[], item) => { return items.reduce((acc: MenuItem[], item) => {
if (item.id === id) { if (item.id === id) return acc
return acc // Skip this item (remove it)
}
const newItem = { ...item } const newItem = { ...item }
if (newItem.children && newItem.children.length > 0) { if (newItem.children && newItem.children.length > 0) {
@ -92,7 +85,6 @@ export const SortableMenuTree: React.FC<SortableMenuTreeProps> = ({
}, []) }, [])
} }
// Insert item at specific position
const insertItemAtPath = ( const insertItemAtPath = (
items: MenuItem[], items: MenuItem[],
item: MenuItem, item: MenuItem,
@ -117,25 +109,28 @@ export const SortableMenuTree: React.FC<SortableMenuTreeProps> = ({
return newItems return newItems
} }
// Get the path where an item should be inserted based on over item
const getInsertionPath = ( const getInsertionPath = (
items: MenuItem[], items: MenuItem[],
activeId: string, activeId: string,
overId: string, overId: string,
): number[] | null => { ): number[] | null => {
const flatItems = flattenItems(items) const flat = flattenItems(items)
const activeIndex = flatItems.findIndex(({ item }) => item.id === activeId) const activeFlat = flat.find((f) => f.item.id === activeId)
const overIndex = flatItems.findIndex(({ item }) => item.id === overId) const overFlat = flat.find((f) => f.item.id === overId)
if (overIndex === -1) return null if (!activeFlat || !overFlat) return null
const overItem = flatItems[overIndex] const isSameParent = activeFlat.path.slice(0, -1).join() === overFlat.path.slice(0, -1).join()
const insertPath = [...overItem.path]
// Aktif item, listedeki over item'den sonra geliyorsa, yukarı taşınıyordur → over item'ın yerine ekle const insertPath = [...overFlat.path]
// Aktif item, listedeki over item'den önceyse, aşağı taşınıyordur → bir SONRASINA ekle ki yeniden aynı yere düşmesin
if (activeIndex < overIndex) { if (isSameParent) {
insertPath[insertPath.length - 1] += 1 const activeIndex = activeFlat.path[activeFlat.path.length - 2]
const overIndex = overFlat.path[overFlat.path.length - 1]
if (activeIndex < overIndex) {
insertPath[insertPath.length - 1] += 1
}
} }
return insertPath return insertPath
@ -159,50 +154,43 @@ export const SortableMenuTree: React.FC<SortableMenuTreeProps> = ({
const { active, over } = event const { active, over } = event
setActiveItem(null) setActiveItem(null)
if (!over || active.id === over.id || !isDesignMode) { if (!over || active.id === over.id || !isDesignMode) return
return
}
const activeId = active.id as string const activeId = active.id as string
const overId = over.id as string const overId = over.id as string
const activeItem = findItemById(items, activeId) const activeItem = findItemById(items, activeId)
if (!activeItem) return if (!activeItem) return
// ⛳️ Kullanılması gereken liste: `items`
const insertionPath = getInsertionPath(items, activeId, overId) const insertionPath = getInsertionPath(items, activeId, overId)
if (!insertionPath) return if (!insertionPath) return
// Şimdi aktif elemanı çıkar
let newItems = removeItemFromTree(items, activeId) let newItems = removeItemFromTree(items, activeId)
// ve hedef konuma ekle
newItems = insertItemAtPath(newItems, activeItem, insertionPath) newItems = insertItemAtPath(newItems, activeItem, insertionPath)
// Sıra numaralarını güncelle
const finalItems = updateOrderNumbers(newItems) const finalItems = updateOrderNumbers(newItems)
onItemsChange(finalItems) onItemsChange(finalItems)
} }
const renderMenuItem = (item: MenuItem, depth: number = 0): React.ReactNode => { const renderMenuItem = (item: MenuItem, depth: number = 0): React.ReactNode => {
return ( return (
<MenuItemComponent <div key={item.id}>
key={item.id || `temp-${Math.random().toString(36).substr(2, 9)}`} <MenuItemComponent item={item} isDesignMode={isDesignMode} depth={depth} refetch={refetch}>
item={item} {Array.isArray(item.children) && item.children.length > 0 && (
isDesignMode={isDesignMode} <SortableContext
depth={depth} items={item.children
> .filter((child): child is MenuItem & { id: string } => !!child.id)
{item.children && item.children.length > 0 && ( .map((child) => child.id)}
<div className="ml-4"> strategy={verticalListSortingStrategy}
{item.children.map((child) => renderMenuItem(child, depth + 1))} >
</div> <div className="ml-4">
)} {item.children.map((child) => renderMenuItem(child, depth + 1))}
</MenuItemComponent> </div>
</SortableContext>
)}
</MenuItemComponent>
</div>
) )
} }
const allItems = flattenItems(items)
return ( return (
<DndContext <DndContext
sensors={sensors} sensors={sensors}
@ -210,12 +198,7 @@ export const SortableMenuTree: React.FC<SortableMenuTreeProps> = ({
onDragStart={handleDragStart} onDragStart={handleDragStart}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
> >
<SortableContext <div className="space-y-1">{items.map((item) => renderMenuItem(item))}</div>
items={allItems.map(({ item }) => item.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-1">{items.map((item) => renderMenuItem(item))}</div>
</SortableContext>
<DragOverlay> <DragOverlay>
{activeItem ? ( {activeItem ? (
@ -224,6 +207,7 @@ export const SortableMenuTree: React.FC<SortableMenuTreeProps> = ({
isDesignMode={isDesignMode} isDesignMode={isDesignMode}
depth={0} depth={0}
isDragOverlay={true} isDragOverlay={true}
refetch={refetch}
/> />
) : null} ) : null}
</DragOverlay> </DragOverlay>