feat: migrate NavigationMenu to JSON (Tier 2 - Organism 2)

- Create NavigationMenuProps interface for type safety
- Implement useNavigationMenu hook with:
  - expandedGroups state management
  - toggleGroup, expandAll, collapseAll actions
  - isItemVisible, getVisibleItemsCount, getItemBadge utilities
  - handleItemHover, handleItemLeave with route preloading
- Create comprehensive JSON definition with:
  - Nested Collapsible groups for navigation sections
  - Dynamic item rendering with visibility filtering
  - Badge support for error count and item badges
  - Active state styling and hover effects
- Register hook in hooks-registry
- Export from json-components.ts with hook integration
- Update json-components-registry.json (source: organisms, jsonCompatible: true)

All state management delegated to useNavigationMenu hook.
Navigation logic fully expressible in JSON with custom hooks.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-21 01:28:33 +00:00
parent 85fce883dd
commit a78943a854
10 changed files with 774 additions and 6 deletions

View File

@@ -0,0 +1,280 @@
{
"id": "navigation-menu-container",
"type": "Sidebar",
"props": {
"side": "left",
"collapsible": "offcanvas"
},
"children": [
{
"id": "navigation-header",
"type": "SidebarHeader",
"props": {
"className": "px-4 py-4 border-b"
},
"children": [
{
"id": "nav-title",
"type": "h2",
"props": {
"className": "text-lg font-semibold"
},
"bindings": {
"children": {
"source": "navTitle",
"transform": "navTitle || 'Navigation'"
}
}
},
{
"id": "nav-controls",
"type": "div",
"props": {
"className": "flex gap-2 mt-4"
},
"children": [
{
"id": "expand-all-button",
"type": "Button",
"props": {
"variant": "outline",
"size": "sm",
"className": "flex-1"
},
"bindings": {
"onClick": {
"source": "menuState.expandAll",
"transform": "() => menuState.expandAll()"
},
"children": {
"source": "expandAllLabel",
"transform": "expandAllLabel || 'Expand All'"
}
}
},
{
"id": "collapse-all-button",
"type": "Button",
"props": {
"variant": "outline",
"size": "sm",
"className": "flex-1"
},
"bindings": {
"onClick": {
"source": "menuState.collapseAll",
"transform": "() => menuState.collapseAll()"
},
"children": {
"source": "collapseAllLabel",
"transform": "collapseAllLabel || 'Collapse All'"
}
}
}
]
}
]
},
{
"id": "sidebar-content",
"type": "SidebarContent",
"children": [
{
"id": "scroll-area",
"type": "ScrollArea",
"props": {
"className": "h-full px-4"
},
"children": [
{
"id": "groups-container",
"type": "div",
"props": {
"className": "space-y-2 py-4"
},
"children": [
{
"id": "groups-list",
"type": "list",
"bindings": {
"items": "navigationGroups",
"keyPath": "id"
},
"itemTemplate": {
"type": "Collapsible",
"bindings": {
"open": {
"source": "item.id,menuState.expandedGroups",
"transform": "menuState.expandedGroups.has(item.id)"
},
"onOpenChange": {
"source": "item.id",
"transform": "() => menuState.toggleGroup(item.id)"
}
},
"children": [
{
"id": "group-trigger",
"type": "CollapsibleTrigger",
"props": {
"className": "w-full flex items-center gap-2 px-2 py-2 rounded-lg hover:bg-muted transition-colors group"
},
"children": [
{
"id": "group-caret",
"type": "CaretDown",
"props": {
"size": 16,
"weight": "bold"
},
"bindings": {
"className": {
"source": "menuState.expandedGroups",
"transform": "menuState.expandedGroups.has(item.id) ? 'text-muted-foreground transition-transform rotate-0' : 'text-muted-foreground transition-transform -rotate-90'"
}
}
},
{
"id": "group-label",
"type": "h3",
"props": {
"className": "flex-1 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider"
},
"bindings": {
"children": "item.label"
}
},
{
"id": "group-count",
"type": "span",
"props": {
"className": "text-xs text-muted-foreground"
},
"bindings": {
"children": {
"source": "item.items,menuState.isItemVisible",
"transform": "item.items.filter(it => menuState.isItemVisible(it)).length"
}
}
}
]
},
{
"id": "group-content",
"type": "CollapsibleContent",
"props": {
"className": "mt-1"
},
"children": [
{
"id": "items-container",
"type": "div",
"props": {
"className": "space-y-1 pl-2"
},
"children": [
{
"id": "items-list",
"type": "list",
"bindings": {
"items": "item.items",
"keyPath": "id"
},
"itemTemplate": {
"type": "div",
"bindings": {
"onMouseEnter": {
"source": "item.value",
"transform": "() => menuState.handleItemHover(item.value)"
},
"onMouseLeave": {
"source": "item.value",
"transform": "() => menuState.handleItemLeave(item.value)"
}
},
"conditional": {
"if": "menuState.isItemVisible(item)"
},
"children": [
{
"id": "nav-item-button",
"type": "button",
"props": {
"type": "button"
},
"bindings": {
"onClick": {
"source": "item.value,onTabChange",
"transform": "() => onTabChange(item.value)"
},
"className": {
"source": "item.value,activeTab",
"transform": "activeTab === item.value ? 'w-full flex items-center gap-3 px-3 py-2 rounded-lg transition-colors bg-primary text-primary-foreground' : 'w-full flex items-center gap-3 px-3 py-2 rounded-lg transition-colors hover:bg-muted text-foreground'"
}
},
"children": [
{
"id": "item-icon",
"type": "IconWrapper",
"props": {
"size": "md"
},
"bindings": {
"icon": "item.icon",
"variant": {
"source": "item.value,activeTab",
"transform": "activeTab === item.value ? 'default' : 'muted'"
}
}
},
{
"id": "item-label",
"type": "Text",
"props": {
"className": "flex-1 text-left font-medium",
"variant": "small"
},
"bindings": {
"children": "item.label"
}
},
{
"id": "item-badge",
"type": "Badge",
"props": {
"className": "ml-auto"
},
"bindings": {
"variant": {
"source": "item.id,activeTab,errorCount",
"transform": "activeTab === item.value ? 'secondary' : 'destructive'"
},
"children": {
"source": "item.id,errorCount,item.badge",
"transform": "item.id === 'errors' ? errorCount : item.badge"
}
},
"conditional": {
"if": "(item.id === 'errors' && errorCount > 0) || (item.id !== 'errors' && item.badge !== undefined && item.badge > 0)"
}
}
]
}
]
}
}
]
}
]
}
]
}
}
]
}
]
}
]
}
]
}

View File

@@ -0,0 +1,346 @@
{
"id": "tree-list-panel-container",
"type": "div",
"props": {
"className": "w-80 border-r border-border bg-card p-4 flex flex-col gap-4"
},
"children": [
{
"id": "tree-list-header",
"type": "div",
"props": { "className": "space-y-3" },
"children": [
{
"id": "header-flex",
"type": "div",
"props": { "className": "flex items-center justify-between" },
"children": [
{
"id": "header-title",
"type": "div",
"props": { "className": "flex items-center gap-2" },
"children": [
{
"id": "tree-icon",
"type": "TreeIcon",
"props": { "size": 20 }
},
{
"id": "title-heading",
"type": "h2",
"props": { "className": "text-lg font-semibold" },
"children": "Component Trees"
}
]
},
{
"id": "create-button",
"type": "IconButton",
"props": {
"size": "sm"
},
"bindings": {
"onClick": {
"source": "onCreateNew",
"transform": "() => onCreateNew?.()"
}
},
"children": [
{
"id": "add-icon",
"type": "ActionIcon",
"props": {
"action": "add",
"size": 16
}
}
]
}
]
},
{
"id": "action-buttons",
"type": "div",
"props": { "className": "flex gap-2" },
"children": [
{
"id": "import-button",
"type": "Button",
"props": {
"size": "sm",
"variant": "outline",
"className": "flex-1 text-xs"
},
"bindings": {
"onClick": {
"source": "onImportJson",
"transform": "() => onImportJson?.()"
}
},
"children": [
{
"id": "upload-icon",
"type": "ActionIcon",
"props": {
"action": "upload",
"size": 14
}
},
{
"id": "import-text",
"type": "span",
"children": "Import JSON"
}
]
},
{
"id": "export-button",
"type": "Button",
"props": {
"size": "sm",
"variant": "outline",
"className": "flex-1 text-xs"
},
"bindings": {
"onClick": {
"source": "onExportJson",
"transform": "() => onExportJson?.()"
},
"disabled": {
"source": "selectedTreeId",
"transform": "!selectedTreeId"
}
},
"children": [
{
"id": "download-icon",
"type": "ActionIcon",
"props": {
"action": "download",
"size": 14
}
},
{
"id": "export-text",
"type": "span",
"children": "Export JSON"
}
]
}
]
}
]
},
{
"id": "trees-list-wrapper",
"type": "div",
"conditional": { "if": "trees.length === 0" },
"props": { "className": "flex-1 flex flex-col items-center justify-center" },
"children": [
{
"id": "empty-state",
"type": "EmptyState",
"props": {
"title": "No component trees yet",
"description": "Create your first tree to get started",
"action": {
"label": "Create First Tree"
}
},
"bindings": {
"onAction": {
"source": "onCreateNew",
"transform": "() => onCreateNew?.()"
}
}
}
]
},
{
"id": "trees-scroll-area",
"type": "ScrollArea",
"props": { "className": "flex-1" },
"conditional": { "if": "trees.length > 0" },
"children": [
{
"id": "trees-list",
"type": "div",
"props": { "className": "space-y-2" },
"children": [
{
"id": "tree-cards",
"type": "list",
"bindings": {
"items": "trees",
"keyPath": "id"
},
"itemTemplate": {
"id": "tree-card-item",
"type": "Card",
"bindings": {
"className": {
"source": "selectedTreeId,item.id",
"transform": "item.id === selectedTreeId ? 'cursor-pointer transition-all p-4 ring-2 ring-primary bg-accent' : 'cursor-pointer transition-all p-4 hover:bg-accent/50'"
},
"onClick": {
"source": "onTreeSelect,item.id",
"transform": "() => onTreeSelect?.(item.id)"
}
},
"children": [
{
"id": "card-content",
"type": "div",
"props": { "className": "space-y-3" },
"children": [
{
"id": "card-header",
"type": "div",
"props": { "className": "flex items-start justify-between gap-2" },
"children": [
{
"id": "card-title-wrapper",
"type": "div",
"props": { "className": "flex-1 min-w-0" },
"children": [
{
"id": "card-title",
"type": "h4",
"props": { "className": "text-sm truncate font-semibold" },
"bindings": { "children": "item.name" }
},
{
"id": "card-description",
"type": "p",
"props": { "className": "text-xs text-muted-foreground line-clamp-2" },
"bindings": { "children": "item.description" },
"conditional": { "if": "item.description" }
},
{
"id": "card-badge",
"type": "div",
"props": { "className": "mt-2" },
"children": [
{
"id": "component-count-badge",
"type": "Badge",
"props": { "variant": "outline", "className": "text-xs" },
"bindings": {
"children": {
"source": "item.rootNodes.length",
"transform": "`${item.rootNodes.length} components`"
}
}
}
]
}
]
}
]
},
{
"id": "card-actions",
"type": "div",
"props": { "className": "mt-2 flex gap-1" },
"bindings": {
"onClick": {
"source": "none",
"transform": "(e) => e.stopPropagation()"
}
},
"children": [
{
"id": "edit-button",
"type": "IconButton",
"props": {
"variant": "ghost",
"size": "sm",
"title": "Edit tree"
},
"bindings": {
"onClick": {
"source": "onTreeEdit,item",
"transform": "(e) => { e.stopPropagation(); onTreeEdit?.(item); }"
}
},
"children": [
{
"id": "edit-icon",
"type": "ActionIcon",
"props": {
"action": "edit",
"size": 14
}
}
]
},
{
"id": "duplicate-button",
"type": "IconButton",
"props": {
"variant": "ghost",
"size": "sm",
"title": "Duplicate tree"
},
"bindings": {
"onClick": {
"source": "onTreeDuplicate,item",
"transform": "(e) => { e.stopPropagation(); onTreeDuplicate?.(item); }"
}
},
"children": [
{
"id": "duplicate-icon",
"type": "ActionIcon",
"props": {
"action": "duplicate",
"size": 14
}
}
]
},
{
"id": "delete-button",
"type": "IconButton",
"props": {
"variant": "ghost",
"size": "sm"
},
"bindings": {
"onClick": {
"source": "onTreeDelete,item.id",
"transform": "(e) => { e.stopPropagation(); onTreeDelete?.(item.id); }"
},
"disabled": {
"source": "trees.length",
"transform": "trees.length === 1"
},
"title": {
"source": "trees.length",
"transform": "trees.length === 1 ? \"Can't delete last tree\" : \"Delete tree\""
}
},
"children": [
{
"id": "delete-icon",
"type": "ActionIcon",
"props": {
"action": "delete",
"size": 14
}
}
]
}
]
}
]
}
]
}
}
]
}
]
}
]
}

View File

@@ -39,3 +39,5 @@ export * from './use-accordion'
export * from './use-binding-editor'
export { useAppLayout } from './use-app-layout'
export { useAppRouterLayout } from './use-app-router-layout'
export { useNavigationMenu } from './use-navigation-menu'
export { useDataSourceManagerState } from './use-data-source-manager-state'

View File

@@ -0,0 +1,90 @@
import { useState } from 'react'
import { navigationGroups, NavigationItemData } from '@/lib/navigation-config'
import { FeatureToggles } from '@/types/project'
import { useRoutePreload } from './use-route-preload'
interface UseNavigationMenuState {
expandedGroups: Set<string>
toggleGroup: (groupId: string) => void
expandAll: () => void
collapseAll: () => void
isItemVisible: (item: NavigationItemData) => boolean
getVisibleItemsCount: (groupId: string) => number
getItemBadge: (item: NavigationItemData, errorCount: number) => number | undefined
handleItemHover: (value: string) => void
handleItemLeave: (value: string) => void
}
export function useNavigationMenu(featureToggles: FeatureToggles, errorCount: number = 0): UseNavigationMenuState {
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(
new Set(['overview', 'development', 'automation', 'design', 'backend', 'testing', 'tools'])
)
const { preloadRoute, cancelPreload } = useRoutePreload({ delay: 100 })
const isItemVisible = (item: NavigationItemData) => {
if (!item.featureKey) return true
return featureToggles[item.featureKey]
}
const getVisibleItemsCount = (groupId: string) => {
const group = navigationGroups.find((g) => g.id === groupId)
if (!group) return 0
return group.items.filter(isItemVisible).length
}
const getItemBadge = (item: NavigationItemData, errorCount: number) => {
if (item.id === 'errors') return errorCount
return item.badge
}
const toggleGroup = (groupId: string) => {
setExpandedGroups((prev) => {
const newSet = new Set(prev)
if (newSet.has(groupId)) {
newSet.delete(groupId)
} else {
newSet.add(groupId)
}
return newSet
})
}
const expandAll = () => {
const allGroupIds = navigationGroups
.filter((group) =>
group.items.some((item) => {
if (!item.featureKey) return true
return featureToggles[item.featureKey]
})
)
.map((group) => group.id)
setExpandedGroups(new Set(allGroupIds))
}
const collapseAll = () => {
setExpandedGroups(new Set())
}
const handleItemHover = (value: string) => {
console.log(`[NAV] 🖱️ Hover detected on: ${value}`)
preloadRoute(value)
}
const handleItemLeave = (value: string) => {
console.log(`[NAV] 👋 Hover left: ${value}`)
cancelPreload(value)
}
return {
expandedGroups,
toggleGroup,
expandAll,
collapseAll,
isItemVisible,
getVisibleItemsCount,
getItemBadge,
handleItemHover,
handleItemLeave,
}
}

View File

@@ -17,6 +17,8 @@ import { useAccordion } from '@/hooks/use-accordion'
import { useBindingEditor } from '@/hooks/use-binding-editor'
import { useAppLayout } from '@/hooks/use-app-layout'
import { useAppRouterLayout } from '@/hooks/use-app-router-layout'
import { useNavigationMenu } from '@/hooks/use-navigation-menu'
import { useDataSourceManagerState } from '@/hooks/use-data-source-manager-state'
export interface HookRegistry {
[key: string]: (...args: any[]) => any
@@ -41,6 +43,8 @@ export const hooksRegistry: HookRegistry = {
useBindingEditor,
useAppLayout,
useAppRouterLayout,
useNavigationMenu,
useDataSourceManagerState,
// Add more hooks here as needed
}

View File

@@ -22,7 +22,10 @@ export * from './menu'
export * from './file-upload'
export * from './accordion'
export * from './binding-editor'
export * from './tree-list-panel'
export * from './app-layout'
export * from './app-router-layout'
export * from './app-main-panel'
export * from './app-dialogs'
export * from './data-source-manager'
export * from './navigation-menu'

View File

@@ -0,0 +1,8 @@
import type { FeatureToggles } from '@/types/project'
export interface NavigationMenuProps {
activeTab: string
onTabChange: (tab: string) => void
featureToggles: FeatureToggles
errorCount?: number
}

View File

@@ -0,0 +1,13 @@
import type { ComponentTree } from '@/types/project'
export interface TreeListPanelProps {
trees: ComponentTree[]
selectedTreeId: string | null
onTreeSelect: (treeId: string) => void
onTreeEdit: (tree: ComponentTree) => void
onTreeDuplicate: (tree: ComponentTree) => void
onTreeDelete: (treeId: string) => void
onCreateNew: () => void
onImportJson: () => void
onExportJson: () => void
}

View File

@@ -34,6 +34,9 @@ import type {
AppRouterLayoutProps,
AppMainPanelProps,
AppDialogsProps,
DataSourceManagerProps,
NavigationMenuProps,
TreeListPanelProps,
} from './interfaces'
// Import JSON definitions
@@ -63,6 +66,9 @@ import appLayoutDef from '@/components/json-definitions/app-layout.json'
import appRouterLayoutDef from '@/components/json-definitions/app-router-layout.json'
import appMainPanelDef from '@/components/json-definitions/app-main-panel.json'
import appDialogsDef from '@/components/json-definitions/app-dialogs.json'
import navigationMenuDef from '@/components/json-definitions/navigation-menu.json'
import dataSourceManagerDef from '@/components/json-definitions/data-source-manager.json'
import treeListPanelDef from '@/components/json-definitions/tree-list-panel.json'
// Create pure JSON components (no hooks)
export const LoadingFallback = createJsonComponent<LoadingFallbackProps>(loadingFallbackDef)
@@ -215,4 +221,24 @@ export const AppRouterLayout = createJsonComponentWithHooks<AppRouterLayoutProps
export const AppMainPanel = createJsonComponent<AppMainPanelProps>(appMainPanelDef)
export const DataSourceManager = createJsonComponentWithHooks<DataSourceManagerProps>(dataSourceManagerDef, {
hooks: {
managerState: {
hookName: 'useDataSourceManagerState',
args: (props) => [props.dataSources || [], props.onChange || (() => {})]
}
}
})
export const NavigationMenu = createJsonComponentWithHooks<NavigationMenuProps>(navigationMenuDef, {
hooks: {
menuState: {
hookName: 'useNavigationMenu',
args: (props) => [props.featureToggles, props.errorCount || 0]
}
}
})
export const TreeListPanel = createJsonComponent<TreeListPanelProps>(treeListPanelDef)
// All components converted to pure JSON! 🎉