diff --git a/ui/src/utils/hooks/useMenuData.ts b/ui/src/utils/hooks/useMenuData.ts index 0af2b026..52397895 100644 --- a/ui/src/utils/hooks/useMenuData.ts +++ b/ui/src/utils/hooks/useMenuData.ts @@ -1,118 +1,89 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { MenuItem } from '@/proxy/menus/menu' import { getMenus, MenuService } from '@/services/menu.service' import { useStoreActions } from '@/store/store' -import { useState, useEffect } from 'react' -import useAuth from './useAuth' +import { + buildMenuTree, + flattenMenuTree, + menuTreeSignature, + reindexTree, +} from '@/views/menu/menuTree.utils' + +const menuService = new MenuService() export const useMenuData = () => { const [menuItems, setMenuItems] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) - const { getConfig } = useStoreActions((a) => a.abpConfig) - const { authenticated } = useAuth() + const [baseline, setBaseline] = useState('') - const buildHierarchy = (items: MenuItem[]): MenuItem[] => { - const itemMap = new Map() - const rootItems: MenuItem[] = [] + const { getConfig } = useStoreActions((actions) => actions.abpConfig) - // Create a map for quick lookup and initialize children arrays - items.forEach((item) => { - itemMap.set(item.code!!, { ...item, children: [] }) - }) + const mountedRef = useRef(true) + const requestIdRef = useRef(0) - // Build the hierarchy - items.forEach((item) => { - const menuItem = itemMap.get(item.code!!)! - - if (item.parentCode && itemMap.has(item.parentCode)) { - const parent = itemMap.get(item.parentCode)! - if (!parent.children) { - parent.children = [] - } - parent.children.push(menuItem) - } else { - rootItems.push(menuItem) - } - }) - - // Sort items by order recursively - const sortItems = (items: MenuItem[]): MenuItem[] => { - return items - .sort((a, b) => a.order - b.order) - .map((item) => ({ - ...item, - children: item.children && item.children.length > 0 ? sortItems(item.children) : [], - })) + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false } + }, []) - return sortItems(rootItems) - } + const fetchMenuData = useCallback(async () => { + const requestId = ++requestIdRef.current + const isCurrent = () => mountedRef.current && requestId === requestIdRef.current + + setLoading(true) + setError(null) - const fetchMenuData = async () => { try { - setLoading(true) - setError(null) - - // Simulate API call with mock data - await new Promise((resolve) => setTimeout(resolve, 1000)) - const response = await getMenus() + if (!isCurrent()) return - if (response.data) { - const hierarchicalMenu = buildHierarchy(response.data.items || []) - setMenuItems(hierarchicalMenu) - } + const tree = reindexTree(buildMenuTree(response.data?.items ?? [])) + setMenuItems(tree) + setBaseline(menuTreeSignature(tree)) } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load menu data') + if (isCurrent()) { + setError(err instanceof Error ? err.message : 'Failed to load menu data') + } } finally { - setLoading(false) + if (isCurrent()) setLoading(false) } - } + }, []) - const saveMenuData = async (updatedMenuItems: MenuItem[]) => { - const menuService = new MenuService() + const saveMenuData = useCallback( + async (updatedMenuItems: MenuItem[]) => { + const normalized = reindexTree(updatedMenuItems) - try { - // Flatten the hierarchy for API - const flatten = ( - items: MenuItem[], - parentCode: string | undefined = undefined, - ): MenuItem[] => { - const result: MenuItem[] = [] - items.forEach((item, index) => { - const flatItem = { - ...item, - parentCode, - order: index + 1, - children: undefined, - } - result.push(flatItem) + await menuService.updateAll(flattenMenuTree(normalized)) - if (item.children && item.children.length > 0) { - result.push(...flatten(item.children, item.code)) - } - }) - return result + if (mountedRef.current) { + setMenuItems(normalized) + setBaseline(menuTreeSignature(normalized)) } - const items = flatten(updatedMenuItems) - await menuService.updateAll(items) - getConfig(false) - return { success: true } - } catch (err) { - throw new Error(err instanceof Error ? err.message : 'Failed to save menu data') - } - } + // Sol menü / yetki cache'ini tazele + getConfig(false) + }, + [getConfig], + ) useEffect(() => { fetchMenuData() - }, []) + }, [fetchMenuData]) + + const isDirty = useMemo( + () => menuItems.length > 0 && menuTreeSignature(menuItems) !== baseline, + [menuItems, baseline], + ) return { menuItems, setMenuItems, loading, error, + isDirty, refetch: fetchMenuData, saveMenuData, } diff --git a/ui/src/views/menu/MenuItemComponent.tsx b/ui/src/views/menu/MenuItemComponent.tsx index ed21feb0..2a3e32c8 100644 --- a/ui/src/views/menu/MenuItemComponent.tsx +++ b/ui/src/views/menu/MenuItemComponent.tsx @@ -1,408 +1,546 @@ -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 React, { memo, useMemo, useState } from 'react' +import { Field, FieldProps, Form, Formik } from 'formik' +import * as Yup from 'yup' +import { + FaChevronDown, + FaChevronRight, + FaExternalLinkAlt, + FaIndent, + FaLock, + FaOutdent, + FaPen, + FaPlus, + FaQuestionCircle, + FaTrashAlt, +} from 'react-icons/fa' import { Button, Dialog, FormContainer, FormItem, Input, - Switcher, Notification, - toast, Select, + Switcher, + toast, } from '@/components/ui' -import { Field, FieldProps, Form, Formik } from 'formik' -import { SelectBoxOption } from '@/types/shared' -import * as Yup from 'yup' -import { FaChevronDown, FaChevronRight, FaQuestionCircle, FaPlus, FaTrashAlt } from 'react-icons/fa' +import { ConfirmDialog } from '@/components/shared' +import { MenuItem } from '@/proxy/menus/menu' import { MenuDto } from '@/proxy/menus/models' +import navigationIcon from '@/proxy/menus/navigation-icon.config' +import { MenuService } from '@/services/menu.service' +import { SelectBoxOption } from '@/types/shared' +import { useLocalization } from '@/utils/hooks/useLocalization' +import { IconPickerField } from '@/views/shared/MenuAddDialog' -interface MenuItemComponentProps { +const menuService = new MenuService() + +const validationSchema = Yup.object().shape({ + code: Yup.string().trim().required().matches(/^\S+$/, 'Code cannot contain spaces'), + displayName: Yup.string().trim().required(), + order: Yup.number().min(0).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 inputClass = 'h-8 text-sm px-2 dark:bg-gray-900 dark:text-gray-100' +const stopPropagation = (event: React.SyntheticEvent) => event.stopPropagation() + +export interface MenuItemComponentProps { item: MenuItem isDesignMode: boolean depth: number children?: React.ReactNode isDragOverlay?: boolean - refetch: () => void + isDragging?: boolean + isHighlighted?: boolean + isExpanded?: boolean + canIndent?: boolean + canOutdent?: boolean permissions: SelectBoxOption[] - expansionCommand: { expanded: boolean; version: number } + refetch: () => void + onToggleExpand?: (id: string) => void + onIndent?: (id: string) => void + onOutdent?: (id: string) => void + setNodeRef?: (node: HTMLElement | null) => void + dragHandleProps?: Record + style?: React.CSSProperties } -export const MenuItemComponent: React.FC = ({ +const MenuItemComponentBase: React.FC = ({ item, isDesignMode, depth, children, isDragOverlay = false, - refetch, + isDragging = false, + isHighlighted = false, + isExpanded = true, + canIndent = false, + canOutdent = false, permissions, - expansionCommand, + refetch, + onToggleExpand, + onIndent, + onOutdent, + setNodeRef, + dragHandleProps, + style, }) => { 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(), - displayName: Yup.string().required(), - order: Yup.number().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 [formData, setFormData] = useState>({}) + const [isConfirmOpen, setIsConfirmOpen] = useState(false) + const [isDeleting, setIsDeleting] = useState(false) - useEffect(() => { - setIsExpanded(expansionCommand.expanded) - }, [expansionCommand.expanded, expansionCommand.version]) + const childCount = item.children?.length ?? 0 + const hasChildren = childCount > 0 + const IconComponent = (item.icon && navigationIcon[item.icon]) || FaQuestionCircle + const displayLabel = item.displayName ? translate('::' + item.displayName) : (item.code ?? '') - const getCreateInitialValues = (): Partial => ({ - code: '', - displayName: '', - order: (item.children?.length || 0) + 1, - parentCode: item.code, - url: '', - icon: '', - cssClass: '', - requiredPermissionName: '', - target: '', - isDisabled: false, - elementId: '', - }) + // Satırda aynı bilgiyi tekrar etmemek için: permission, code ile aynıysa + // sadece kilit ikonu gösterilir (tam ad tooltip'te). + const permissionLabel = + item.requiredPermissionName && item.requiredPermissionName !== item.code + ? item.requiredPermissionName + : '' - const getEditInitialValues = (): Partial => ({ - 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 createInitialValues = useMemo>( + () => ({ + code: '', + displayName: '', + order: childCount + 1, + parentCode: item.code, + url: '', + icon: '', + cssClass: '', + requiredPermissionName: '', + target: '', + isDisabled: false, + elementId: '', + }), + [childCount, item.code], + ) - const [formData, setFormData] = useState>(getCreateInitialValues()) + const editInitialValues = useMemo>( + () => ({ + 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 ?? '', + }), + [item], + ) - const toggleExpanded = (event?: React.MouseEvent) => { + const notify = (type: 'success' | 'danger', message: string) => + toast.push( + + {message} + , + { placement: 'bottom-end' }, + ) + + const toggleExpanded = (event?: React.SyntheticEvent) => { event?.stopPropagation() - setIsExpanded((current) => !current) + if (item.id && hasChildren) onToggleExpand?.(item.id) } - const openCreateModal = () => { + const openCreateModal = (event: React.MouseEvent) => { + event.stopPropagation() setModalMode('create') - setFormData(getCreateInitialValues()) + setFormData(createInitialValues) setIsModalOpen(true) } const openEditModal = (event: React.MouseEvent) => { - if (!isDesignMode) return event.stopPropagation() + if (!isDesignMode || isDragOverlay) return setModalMode('edit') - setFormData(getEditInitialValues()) + setFormData(editInitialValues) 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() + if (!item.id) return + + try { + setIsDeleting(true) + await menuService.delete(item.id) + notify('success', translate('::KayitSilindi')) + refetch() + } catch (error) { + notify('danger', error instanceof Error ? error.message : translate('::IslemBasarisiz')) + } finally { + setIsDeleting(false) + setIsConfirmOpen(false) + } } + const rowStateClass = isHighlighted + ? 'bg-amber-50 dark:bg-amber-900/40 ring-1 ring-amber-400' + : hasChildren + ? 'bg-blue-50 dark:bg-blue-900' + : depth === 0 + ? 'bg-white dark:bg-gray-800' + : 'bg-gray-50 dark:bg-gray-900' + return (
0 ? 'bg-blue-50 dark:bg-blue-900' : depth === 0 ? 'bg-white dark:bg-gray-800' : 'bg-gray-50 dark:bg-gray-900'} + ${item.isDisabled ? 'opacity-60' : ''} + ${rowStateClass} `} - {...(isDesignMode ? { ...attributes, ...listeners } : { onClick: toggleExpanded })} + {...(isDesignMode ? dragHandleProps : { onClick: toggleExpanded })} > - {isDesignMode && - (item.children && item.children.length > 0 ? ( -
+ {hasChildren ? ( +
-
- {isDesignMode && ( -
- #{item.order} -
+
+ {/* Sabit genişlikli meta kolonu — sağdaki rozet/aksiyonların hizası bozulmaz */} +
+ {item.url && ( + + + {item.url} + + )} + + {item.requiredPermissionName && ( + + + {permissionLabel && {permissionLabel}} + + )} +
+ + {item.isDisabled && ( + + {translate('::Disabled')} + )} - {item.children && item.children.length > 0 && ( - - {item.children.length} + {isDesignMode && ( + + #{item.order} )} + + + {hasChildren && ( + + {childCount} + + )} + + + {isDesignMode && !isDragOverlay && ( +
+
+ )}
{children && isExpanded &&
{children}
} + {isConfirmOpen && ( + !isDeleting && setIsConfirmOpen(false)} + onRequestClose={() => !isDeleting && setIsConfirmOpen(false)} + onConfirm={handleDelete} + > +

+ {displayLabel} + {hasChildren ? ` (+${childCount})` : ''} +

+
+ )} + {isModalOpen && ( setIsModalOpen(false)} onRequestClose={() => setIsModalOpen(false)} - width={600} >
- {modalMode === 'edit' ? translate('::Edit Menu Item') : translate('::New Item')} + {modalMode === 'edit' ? translate('::Edit') : translate('::New')}
{ 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( - - {modalMode === 'edit' - ? translate('::KayitGuncellendi') - : translate('::KayitEklendi')} - , - { placement: 'bottom-end' }, + + notify( + 'success', + modalMode === 'edit' + ? translate('::KayitGuncellendi') + : translate('::KayitEklendi'), ) setIsModalOpen(false) refetch() } catch (error) { - console.error('Failed to save menu item:', error) - toast.push( - - {translate('::IslemBasarisiz')} - , - { placement: 'bottom-end' }, + notify( + 'danger', + error instanceof Error ? error.message : translate('::IslemBasarisiz'), ) } finally { setSubmitting(false) } }} > - {({ values, isSubmitting }) => ( + {({ values, errors, touched, isSubmitting, setFieldValue }) => (
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + - {({ field, form }: FieldProps) => ( - + + + + + {({ field, form }: FieldProps) => ( + setSearchTerm(event.target.value)} + /> + {isFiltered && ( + @@ -134,7 +198,7 @@ export const MenuManager = () => { size="sm" variant="plain" className="!h-auto !rounded-lg !px-2 !py-1" - icon={} + icon={} onClick={() => setAllExpanded(true)} > {translate('::ListForms.ListFormEdit.ExpandAll')} @@ -143,7 +207,7 @@ export const MenuManager = () => { size="sm" variant="plain" className="!h-auto !rounded-lg !px-2 !py-1" - icon={} + icon={} onClick={() => setAllExpanded(false)} > {translate('::ListForms.ListFormEdit.CollapseAll')} @@ -151,44 +215,50 @@ export const MenuManager = () => { - {menuItems.length > 0 ? ( + {isFiltered && ( +

+ {matchedIds.size} {translate('::Results')} — {translate('::DragDisabledWhileFiltering')} +

+ )} + + {visibleItems.length > 0 ? ( ) : (
-

No menu items found

-

Try refreshing the page or contact your administrator

+

{translate('::NoDataFound')}

)} diff --git a/ui/src/views/menu/SortableMenuTree.tsx b/ui/src/views/menu/SortableMenuTree.tsx index 36378a81..13168513 100644 --- a/ui/src/views/menu/SortableMenuTree.tsx +++ b/ui/src/views/menu/SortableMenuTree.tsx @@ -1,248 +1,218 @@ -import React, { useEffect, useState } from 'react' +import React, { useCallback, useEffect, useMemo, useState } from 'react' import { DndContext, DragOverlay, - closestCenter, + DragEndEvent, + DragStartEvent, KeyboardSensor, PointerSensor, + closestCenter, useSensor, useSensors, - DragStartEvent, - DragEndEvent, } from '@dnd-kit/core' import { SortableContext, sortableKeyboardCoordinates, + useSortable, verticalListSortingStrategy, } from '@dnd-kit/sortable' -import { MenuItemComponent } from './MenuItemComponent' +import { CSS } from '@dnd-kit/utilities' +import { MenuItemComponent, MenuItemComponentProps } from './MenuItemComponent' +import { findMenuItem, indentMenuItem, moveMenuItem, outdentMenuItem } from './menuTree.utils' import { MenuItem } from '@/proxy/menus/menu' -import { getPermissionsList } from '@/services/identity.service' import { PermissionDefinitionRecord } from '@/proxy/admin/models' +import { getPermissionsList } from '@/services/identity.service' import { SelectBoxOption } from '@/types/shared' interface SortableMenuTreeProps { + /** Ekranda gösterilen (filtrelenmiş olabilir) ağaç */ items: MenuItem[] + /** Taşıma işlemlerinin uygulanacağı tam ağaç */ + sourceItems: MenuItem[] onItemsChange: (items: MenuItem[]) => void isDesignMode: boolean + isFiltered?: boolean + matchedIds?: Set + collapsedIds: Set + onToggleExpand: (id: string) => void refetch: () => void - expansionCommand: { expanded: boolean; version: number } +} + +type SortableRowProps = Omit & { + disabled: boolean +} + +const SortableMenuRow: React.FC = ({ disabled, ...props }) => { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: props.item.id ?? '', + data: { type: 'menu-item' }, + disabled, + }) + + return ( + + ) } export const SortableMenuTree: React.FC = ({ items, + sourceItems, onItemsChange, isDesignMode, + isFiltered = false, + matchedIds, + collapsedIds, + onToggleExpand, refetch, - expansionCommand, }) => { const [permissions, setPermissions] = useState([]) - const [activeItem, setActiveItem] = React.useState(null) + const [activeItem, setActiveItem] = useState(null) + + // Filtre açıkken ara düğümler gizli olduğu için sürükle-bırak kapatılır. + const dragEnabled = isDesignMode && !isFiltered const sensors = useSensors( - useSensor(PointerSensor, { - activationConstraint: { - distance: 8, - }, - }), - useSensor(KeyboardSensor, { - coordinateGetter: sortableKeyboardCoordinates, - }), + useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), ) - const flattenItems = ( - items: MenuItem[], - parentPath: number[] = [], - ): Array<{ item: MenuItem; path: number[] }> => { - return items.flatMap((item, index) => { - const path = [...parentPath, index] - const self = { item, path } - - if (item.children?.length) { - return [self, ...flattenItems(item.children, path)] - } - - return [self] - }) - } - - const findItemById = (items: MenuItem[], id: string): MenuItem | null => { - for (const item of items) { - if (item.id === id) return item - if (item.children) { - const found = findItemById(item.children, id) - if (found) return found - } - } - return null - } - - const removeItemFromTree = (items: MenuItem[], id: string): MenuItem[] => { - return items.reduce((acc: MenuItem[], item) => { - if (item.id === id) return acc - - const newItem = { ...item } - if (newItem.children && newItem.children.length > 0) { - newItem.children = removeItemFromTree(newItem.children, id) - } - - acc.push(newItem) - return acc - }, []) - } - - const insertItemAtPath = ( - items: MenuItem[], - item: MenuItem, - targetPath: number[], - ): MenuItem[] => { - if (targetPath.length === 1) { - const newItems = [...items] - newItems.splice(targetPath[0], 0, item) - return newItems - } - - const [firstIndex, ...restPath] = targetPath - const newItems = [...items] - - if (newItems[firstIndex]) { - newItems[firstIndex] = { - ...newItems[firstIndex], - children: insertItemAtPath(newItems[firstIndex].children || [], item, restPath), - } - } - - return newItems - } - - const getInsertionPath = ( - items: MenuItem[], - activeId: string, - overId: string, - ): number[] | null => { - const flat = flattenItems(items) - const activeFlat = flat.find((f) => f.item.id === activeId) - const overFlat = flat.find((f) => f.item.id === overId) - - if (!activeFlat || !overFlat) return null - - const isSameParent = activeFlat.path.slice(0, -1).join() === overFlat.path.slice(0, -1).join() - - const insertPath = [...overFlat.path] - - if (isSameParent) { - 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 - } - - const updateOrderNumbers = (items: MenuItem[]): MenuItem[] => { - return items.map((item, index) => ({ - ...item, - order: index + 1, - children: item.children ? updateOrderNumbers(item.children) : [], - })) - } - - const handleDragStart = (event: DragStartEvent) => { - const { active } = event - const activeItem = findItemById(items, active.id as string) - setActiveItem(activeItem) - } - - const handleDragEnd = (event: DragEndEvent) => { - const { active, over } = event - setActiveItem(null) - - if (!over || active.id === over.id || !isDesignMode) return - - const activeId = active.id as string - const overId = over.id as string - const activeItem = findItemById(items, activeId) - if (!activeItem) return - - const insertionPath = getInsertionPath(items, activeId, overId) - if (!insertionPath) return - - let newItems = removeItemFromTree(items, activeId) - newItems = insertItemAtPath(newItems, activeItem, insertionPath) - const finalItems = updateOrderNumbers(newItems) - onItemsChange(finalItems) - } - useEffect(() => { - const fetchPermissions = async () => { - const response = await getPermissionsList() - if (response.data) { - setPermissions( - response.data.map((p: PermissionDefinitionRecord) => ({ - value: p.name, - label: p.name, - })), - ) - } - } + let cancelled = false - fetchPermissions() + getPermissionsList() + .then((response) => { + if (cancelled || !response.data) return + + const options = response.data + .filter((permission: PermissionDefinitionRecord) => Boolean(permission.name)) + .map((permission: PermissionDefinitionRecord) => ({ + value: permission.name, + label: permission.name, + })) + .sort((a, b) => (a.label ?? '').localeCompare(b.label ?? '')) + + setPermissions(options) + }) + .catch(() => { + if (!cancelled) setPermissions([]) + }) + + return () => { + cancelled = true + } }, []) - const renderMenuItem = (item: MenuItem, depth: number = 0): React.ReactNode => { + const handleDragStart = useCallback( + (event: DragStartEvent) => setActiveItem(findMenuItem(sourceItems, event.active.id as string)), + [sourceItems], + ) + + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + setActiveItem(null) + + const { active, over } = event + if (!over || !dragEnabled || active.id === over.id) return + + const next = moveMenuItem(sourceItems, active.id as string, over.id as string) + if (next) onItemsChange(next) + }, + [dragEnabled, onItemsChange, sourceItems], + ) + + const handleIndent = useCallback( + (id: string) => { + const next = indentMenuItem(sourceItems, id) + if (next) onItemsChange(next) + }, + [onItemsChange, sourceItems], + ) + + const handleOutdent = useCallback( + (id: string) => { + const next = outdentMenuItem(sourceItems, id) + if (next) onItemsChange(next) + }, + [onItemsChange, sourceItems], + ) + + const renderMenuItem = (item: MenuItem, depth: number, index: number) => { + const id = item.id ?? '' + const children = item.children ?? [] + const isExpanded = isFiltered || !collapsedIds.has(id) + return ( -
- + 0} + canOutdent={isDesignMode && !isFiltered && depth > 0} depth={depth} - refetch={refetch} + disabled={!dragEnabled} + isDesignMode={isDesignMode} + isExpanded={isExpanded} + isHighlighted={Boolean(matchedIds?.has(id))} + item={item} permissions={permissions} - expansionCommand={expansionCommand} + refetch={refetch} + onIndent={handleIndent} + onOutdent={handleOutdent} + onToggleExpand={onToggleExpand} > - {Array.isArray(item.children) && item.children.length > 0 && ( + {children.length > 0 && ( !!child.id) - .map((child) => child.id)} + items={children.map((child) => child.id ?? '').filter(Boolean)} strategy={verticalListSortingStrategy} > -
- {item.children.map((child) => renderMenuItem(child, depth + 1))} +
+ {children.map((child, childIndex) => + renderMenuItem(child, depth + 1, childIndex), + )}
)} - +
) } + const rootIds = useMemo( + () => items.map((item) => item.id ?? '').filter(Boolean), + [items], + ) + return ( setActiveItem(null)} > -
{items.map((item) => renderMenuItem(item))}
+ +
+ {items.map((item, index) => renderMenuItem(item, 0, index))} +
+
- {activeItem ? ( + {activeItem && (
- ) : null} + )}
) diff --git a/ui/src/views/menu/menuTree.utils.ts b/ui/src/views/menu/menuTree.utils.ts new file mode 100644 index 00000000..e4127890 --- /dev/null +++ b/ui/src/views/menu/menuTree.utils.ts @@ -0,0 +1,274 @@ +import type { MenuItem } from '@/proxy/menus/menu' +import type { MenuDto } from '@/proxy/menus/models' + +/** Aynı seviyedeki kayıtları order (eşitse displayName) ile sıralar. */ +const sortByOrder = (items: MenuItem[]): MenuItem[] => { + items.sort( + (a, b) => + (a.order ?? 0) - (b.order ?? 0) || + (a.displayName ?? '').localeCompare(b.displayName ?? ''), + ) + items.forEach((item) => { + if (item.children?.length) sortByOrder(item.children) + }) + return items +} + +/** + * Düz listeyi ağaca çevirir. + * - parentCode karşılığı olmayan kayıtlar root'a alınır (orphan koruması). + * - Çevrimsel parent zincirleri (A -> B -> A) tespit edilip root'a taşınır, + * böylece kayıtlar ekranda kaybolmaz. + */ +export const buildMenuTree = (items: MenuDto[]): MenuItem[] => { + const nodes: MenuItem[] = items.map((item) => ({ ...item, children: [] })) + + const byCode = new Map() + nodes.forEach((node) => { + if (node.code && !byCode.has(node.code)) byCode.set(node.code, node) + }) + + const roots: MenuItem[] = [] + nodes.forEach((node) => { + const parent = node.parentCode ? byCode.get(node.parentCode) : undefined + if (parent && parent !== node) parent.children!.push(node) + else roots.push(node) + }) + + // Çevrim nedeniyle hiçbir root'tan ulaşılamayan düğümleri kurtar. + const reachable = new Set() + const visit = (list: MenuItem[]) => { + list.forEach((node) => { + if (reachable.has(node)) return + reachable.add(node) + visit(node.children ?? []) + }) + } + visit(roots) + + nodes.forEach((node) => { + if (reachable.has(node)) return + const parent = node.parentCode ? byCode.get(node.parentCode) : undefined + if (parent?.children) { + parent.children = parent.children.filter((child) => child !== node) + } + roots.push(node) + visit([node]) + }) + + return sortByOrder(roots) +} + +/** Ağacı API'nin beklediği düz listeye çevirir; order/parentCode yeniden hesaplanır. */ +export const flattenMenuTree = (items: MenuItem[], parentCode?: string): MenuDto[] => + items.flatMap((item, index) => { + const { children, ...rest } = item + return [ + { ...rest, parentCode, order: index + 1 }, + ...flattenMenuTree(children ?? [], item.code), + ] + }) + +/** order ve parentCode alanlarını ağaçtaki gerçek konuma göre günceller. */ +export const reindexTree = (items: MenuItem[], parentCode?: string): MenuItem[] => + items.map((item, index) => ({ + ...item, + parentCode, + order: index + 1, + children: item.children?.length ? reindexTree(item.children, item.code) : [], + })) + +export const countMenuItems = (items: MenuItem[]): number => + items.reduce((total, item) => total + 1 + countMenuItems(item.children ?? []), 0) + +/** Alt kaydı olan tüm düğümlerin id'leri (expand/collapse all için). */ +export const collectBranchIds = (items: MenuItem[], acc: string[] = []): string[] => { + items.forEach((item) => { + if (item.children?.length) { + if (item.id) acc.push(item.id) + collectBranchIds(item.children, acc) + } + }) + return acc +} + +export const getItemPath = ( + items: MenuItem[], + id: string, + base: number[] = [], +): number[] | null => { + for (let index = 0; index < items.length; index++) { + const path = [...base, index] + if (items[index].id === id) return path + + const children = items[index].children + if (children?.length) { + const found = getItemPath(children, id, path) + if (found) return found + } + } + return null +} + +export const getItemAtPath = (items: MenuItem[], path: number[]): MenuItem | null => { + const [index, ...rest] = path + const item = items[index] + if (!item) return null + return rest.length ? getItemAtPath(item.children ?? [], rest) : item +} + +export const findMenuItem = (items: MenuItem[], id: string): MenuItem | null => { + const path = getItemPath(items, id) + return path ? getItemAtPath(items, path) : null +} + +const removeAtPath = (items: MenuItem[], path: number[]): MenuItem[] => { + const [index, ...rest] = path + const next = [...items] + + if (!rest.length) { + next.splice(index, 1) + return next + } + + const parent = next[index] + if (!parent) return next + next[index] = { ...parent, children: removeAtPath(parent.children ?? [], rest) } + return next +} + +const insertAtPath = (items: MenuItem[], item: MenuItem, path: number[]): MenuItem[] => { + const [index, ...rest] = path + const next = [...items] + + if (!rest.length) { + next.splice(index, 0, item) + return next + } + + const parent = next[index] + if (!parent) return next + next[index] = { ...parent, children: insertAtPath(parent.children ?? [], item, rest) } + return next +} + +/** a, b'nin atası (veya kendisi) mi? */ +const isAncestorPath = (a: number[], b: number[]) => + a.length <= b.length && a.every((value, index) => b[index] === value) + +/** Ağaçtaki görsel (document) sıraya göre karşılaştırma. */ +const comparePaths = (a: number[], b: number[]) => { + const length = Math.min(a.length, b.length) + for (let i = 0; i < length; i++) { + if (a[i] !== b[i]) return a[i] - b[i] + } + return a.length - b.length +} + +/** + * activeId kaydını overId kaydının kardeşi olacak şekilde taşır. + * Hedef yol, kayıt ağaçtan çıkarıldıktan SONRA yeniden hesaplanır; böylece + * index kayması kaynaklı yanlış konumlanma oluşmaz. + * Bir kaydı kendi alt ağacına bırakmak yok sayılır (veri kaybını önler). + */ +export const moveMenuItem = ( + items: MenuItem[], + activeId: string, + overId: string, +): MenuItem[] | null => { + if (activeId === overId) return null + + const activePath = getItemPath(items, activeId) + const overPath = getItemPath(items, overId) + if (!activePath || !overPath) return null + if (isAncestorPath(activePath, overPath)) return null + + const active = getItemAtPath(items, activePath) + if (!active) return null + + const movingDown = comparePaths(activePath, overPath) < 0 + + const without = removeAtPath(items, activePath) + const nextOverPath = getItemPath(without, overId) + if (!nextOverPath) return null + + const targetPath = movingDown + ? [...nextOverPath.slice(0, -1), nextOverPath[nextOverPath.length - 1] + 1] + : nextOverPath + + return reindexTree(insertAtPath(without, active, targetPath)) +} + +/** Kaydı bir üstteki kardeşin altına taşır. */ +export const indentMenuItem = (items: MenuItem[], id: string): MenuItem[] | null => { + const path = getItemPath(items, id) + if (!path) return null + + const index = path[path.length - 1] + if (index === 0) return null + + const item = getItemAtPath(items, path) + if (!item) return null + + const without = removeAtPath(items, path) + const siblingPath = [...path.slice(0, -1), index - 1] + const sibling = getItemAtPath(without, siblingPath) + if (!sibling) return null + + return reindexTree( + insertAtPath(without, item, [...siblingPath, sibling.children?.length ?? 0]), + ) +} + +/** Kaydı bir üst seviyeye, mevcut ebeveyninin hemen altına taşır. */ +export const outdentMenuItem = (items: MenuItem[], id: string): MenuItem[] | null => { + const path = getItemPath(items, id) + if (!path || path.length < 2) return null + + const item = getItemAtPath(items, path) + if (!item) return null + + const without = removeAtPath(items, path) + const targetPath = [...path.slice(0, -2), path[path.length - 2] + 1] + + return reindexTree(insertAtPath(without, item, targetPath)) +} + +export interface MenuTreeFilterResult { + items: MenuItem[] + matchedIds: Set +} + +/** + * Arama terimine göre ağacı filtreler. + * Eşleşen kaydın tüm alt ağacı korunur, eşleşmeyen ama altında eşleşme olan + * kayıtlar sadece eşleşen dallarıyla birlikte gösterilir. + */ +export const filterMenuTree = (items: MenuItem[], term: string): MenuTreeFilterResult => { + const query = term.trim().toLowerCase() + if (!query) return { items, matchedIds: new Set() } + + const matchedIds = new Set() + + const walk = (nodes: MenuItem[]): MenuItem[] => + nodes.reduce((acc, node) => { + const matchedChildren = walk(node.children ?? []) + const isMatch = [node.displayName, node.code, node.url, node.requiredPermissionName].some( + (value) => value?.toLowerCase().includes(query), + ) + + if (isMatch || matchedChildren.length) { + if (isMatch && node.id) matchedIds.add(node.id) + acc.push({ ...node, children: isMatch ? (node.children ?? []) : matchedChildren }) + } + return acc + }, []) + + return { items: walk(items), matchedIds } +} + +/** Kaydedilmemiş değişiklik tespiti için ağacın konum imzası. */ +export const menuTreeSignature = (items: MenuItem[]): string => + flattenMenuTree(items) + .map((item) => `${item.id ?? item.code}:${item.parentCode ?? ''}:${item.order}`) + .join('|')