mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-01-11 23:02:56 +01:00
527 lines
13 KiB
Markdown
527 lines
13 KiB
Markdown
# Phase 3: Context Menus
|
|
|
|
**Estimated Time:** 1 hour
|
|
**Complexity:** Low
|
|
**Prerequisites:** Phase 2 complete (tree rendering working)
|
|
|
|
## Overview
|
|
|
|
Add context menu functionality for components and folders, including add component menu, rename, duplicate, delete, and make home actions. All actions should integrate with UndoQueue for proper undo/redo support.
|
|
|
|
---
|
|
|
|
## Goals
|
|
|
|
- ✅ Implement header "+" button menu
|
|
- ✅ Implement component right-click context menu
|
|
- ✅ Implement folder right-click context menu
|
|
- ✅ Wire up add component action
|
|
- ✅ Wire up rename action
|
|
- ✅ Wire up duplicate action
|
|
- ✅ Wire up delete action
|
|
- ✅ Wire up make home action
|
|
- ✅ All actions use UndoQueue
|
|
|
|
---
|
|
|
|
## Step 1: Create Add Component Menu
|
|
|
|
### 1.1 Create `components/AddComponentMenu.tsx`
|
|
|
|
Menu for adding new components/folders:
|
|
|
|
```typescript
|
|
/**
|
|
* AddComponentMenu
|
|
*
|
|
* Dropdown menu for adding new components or folders.
|
|
* Integrates with ComponentTemplates system.
|
|
*/
|
|
|
|
import PopupLayer from '@noodl-views/popuplayer';
|
|
import React, { useCallback } from 'react';
|
|
|
|
import { IconName } from '@noodl-core-ui/components/common/Icon';
|
|
|
|
import { ComponentTemplates } from '../../componentspanel/ComponentTemplates';
|
|
|
|
interface AddComponentMenuProps {
|
|
targetElement: HTMLElement;
|
|
onClose: () => void;
|
|
parentPath?: string;
|
|
}
|
|
|
|
export function AddComponentMenu({ targetElement, onClose, parentPath = '' }: AddComponentMenuProps) {
|
|
const handleAddComponent = useCallback(
|
|
(templateId: string) => {
|
|
const template = ComponentTemplates.instance.getTemplate(templateId);
|
|
if (!template) return;
|
|
|
|
// TODO: Create component with template
|
|
// This will integrate with ProjectModel
|
|
console.log('Add component:', templateId, 'at path:', parentPath);
|
|
|
|
onClose();
|
|
},
|
|
[parentPath, onClose]
|
|
);
|
|
|
|
const handleAddFolder = useCallback(() => {
|
|
// TODO: Create new folder
|
|
console.log('Add folder at path:', parentPath);
|
|
onClose();
|
|
}, [parentPath, onClose]);
|
|
|
|
// Build menu items from templates
|
|
const templates = ComponentTemplates.instance.getTemplates();
|
|
const menuItems = templates.map((template) => ({
|
|
icon: template.icon || IconName.Component,
|
|
label: template.displayName || template.name,
|
|
onClick: () => handleAddComponent(template.id)
|
|
}));
|
|
|
|
// Add folder option
|
|
menuItems.push(
|
|
{ type: 'divider' as const },
|
|
{
|
|
icon: IconName.Folder,
|
|
label: 'Folder',
|
|
onClick: handleAddFolder
|
|
}
|
|
);
|
|
|
|
// Show popup menu
|
|
const menu = new PopupLayer.PopupMenu({ items: menuItems });
|
|
|
|
PopupLayer.instance.showPopup({
|
|
content: menu,
|
|
attachTo: targetElement,
|
|
position: 'bottom'
|
|
});
|
|
|
|
return null;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Step 2: Add Context Menu Handlers
|
|
|
|
### 2.1 Update `ComponentItem.tsx`
|
|
|
|
Add right-click handler:
|
|
|
|
```typescript
|
|
export function ComponentItem({ component, level, isSelected, onClick }: ComponentItemProps) {
|
|
const indent = level * 12;
|
|
|
|
const handleContextMenu = useCallback(
|
|
(e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
const menuItems = buildComponentContextMenu(component);
|
|
const menu = new PopupLayer.PopupMenu({ items: menuItems });
|
|
|
|
PopupLayer.instance.showPopup({
|
|
content: menu,
|
|
attachTo: e.currentTarget as HTMLElement,
|
|
position: { x: e.clientX, y: e.clientY }
|
|
});
|
|
},
|
|
[component]
|
|
);
|
|
|
|
// ... existing code ...
|
|
|
|
return (
|
|
<div
|
|
className={classNames(css.TreeItem, {
|
|
[css.Selected]: isSelected
|
|
})}
|
|
style={{ paddingLeft: `${indent + 23}px` }}
|
|
onClick={onClick}
|
|
onContextMenu={handleContextMenu}
|
|
>
|
|
{/* ... existing content ... */}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function buildComponentContextMenu(component: ComponentItemData) {
|
|
return [
|
|
{
|
|
icon: IconName.Plus,
|
|
label: 'Add',
|
|
onClick: () => {
|
|
// TODO: Show add submenu
|
|
}
|
|
},
|
|
{ type: 'divider' as const },
|
|
{
|
|
icon: IconName.Home,
|
|
label: 'Make Home',
|
|
disabled: component.isRoot || !component.canBecomeRoot,
|
|
onClick: () => {
|
|
// TODO: Make component home
|
|
}
|
|
},
|
|
{ type: 'divider' as const },
|
|
{
|
|
icon: IconName.Edit,
|
|
label: 'Rename',
|
|
onClick: () => {
|
|
// TODO: Enable rename mode
|
|
}
|
|
},
|
|
{
|
|
icon: IconName.Copy,
|
|
label: 'Duplicate',
|
|
onClick: () => {
|
|
// TODO: Duplicate component
|
|
}
|
|
},
|
|
{ type: 'divider' as const },
|
|
{
|
|
icon: IconName.Trash,
|
|
label: 'Delete',
|
|
onClick: () => {
|
|
// TODO: Delete component
|
|
}
|
|
}
|
|
];
|
|
}
|
|
```
|
|
|
|
### 2.2 Update `FolderItem.tsx`
|
|
|
|
Add right-click handler:
|
|
|
|
```typescript
|
|
export function FolderItem({
|
|
folder,
|
|
level,
|
|
isExpanded,
|
|
isSelected,
|
|
onCaretClick,
|
|
onClick,
|
|
children
|
|
}: FolderItemProps) {
|
|
const indent = level * 12;
|
|
|
|
const handleContextMenu = useCallback(
|
|
(e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
const menuItems = buildFolderContextMenu(folder);
|
|
const menu = new PopupLayer.PopupMenu({ items: menuItems });
|
|
|
|
PopupLayer.instance.showPopup({
|
|
content: menu,
|
|
attachTo: e.currentTarget as HTMLElement,
|
|
position: { x: e.clientX, y: e.clientY }
|
|
});
|
|
},
|
|
[folder]
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<div
|
|
className={classNames(css.TreeItem, {
|
|
[css.Selected]: isSelected
|
|
})}
|
|
style={{ paddingLeft: `${indent + 10}px` }}
|
|
onContextMenu={handleContextMenu}
|
|
>
|
|
{/* ... existing content ... */}
|
|
</div>
|
|
{children}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function buildFolderContextMenu(folder: FolderItemData) {
|
|
return [
|
|
{
|
|
icon: IconName.Plus,
|
|
label: 'Add',
|
|
onClick: () => {
|
|
// TODO: Show add submenu at folder path
|
|
}
|
|
},
|
|
{ type: 'divider' as const },
|
|
{
|
|
icon: IconName.Home,
|
|
label: 'Make Home',
|
|
disabled: !folder.isComponentFolder || !folder.canBecomeRoot,
|
|
onClick: () => {
|
|
// TODO: Make folder component home
|
|
}
|
|
},
|
|
{ type: 'divider' as const },
|
|
{
|
|
icon: IconName.Edit,
|
|
label: 'Rename',
|
|
onClick: () => {
|
|
// TODO: Enable rename mode for folder
|
|
}
|
|
},
|
|
{
|
|
icon: IconName.Copy,
|
|
label: 'Duplicate',
|
|
onClick: () => {
|
|
// TODO: Duplicate folder
|
|
}
|
|
},
|
|
{ type: 'divider' as const },
|
|
{
|
|
icon: IconName.Trash,
|
|
label: 'Delete',
|
|
onClick: () => {
|
|
// TODO: Delete folder and contents
|
|
}
|
|
}
|
|
];
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Step 3: Implement Action Handlers
|
|
|
|
### 3.1 Create `hooks/useComponentActions.ts`
|
|
|
|
Hook for handling component actions:
|
|
|
|
```typescript
|
|
/**
|
|
* useComponentActions
|
|
*
|
|
* Provides handlers for component/folder actions.
|
|
* Integrates with UndoQueue for all operations.
|
|
*/
|
|
|
|
import { ToastLayer } from '@noodl-views/ToastLayer/ToastLayer';
|
|
import { useCallback } from 'react';
|
|
|
|
import { ProjectModel } from '@noodl-models/projectmodel';
|
|
import { UndoQueue, UndoActionGroup } from '@noodl-models/undo-queue-model';
|
|
|
|
import { ComponentItemData, FolderItemData } from '../types';
|
|
|
|
export function useComponentActions() {
|
|
const handleMakeHome = useCallback((item: ComponentItemData | FolderItemData) => {
|
|
const componentName = item.type === 'component' ? item.fullName : item.path;
|
|
const component = ProjectModel.instance.getComponentWithName(componentName);
|
|
|
|
if (!component) {
|
|
ToastLayer.showError('Component not found');
|
|
return;
|
|
}
|
|
|
|
const previousRoot = ProjectModel.instance.getRootComponent();
|
|
|
|
UndoQueue.instance.pushAndDo(
|
|
new UndoActionGroup({
|
|
label: `Make ${component.name} home`,
|
|
do: () => {
|
|
ProjectModel.instance.setRootComponent(component);
|
|
},
|
|
undo: () => {
|
|
if (previousRoot) {
|
|
ProjectModel.instance.setRootComponent(previousRoot);
|
|
}
|
|
}
|
|
})
|
|
);
|
|
}, []);
|
|
|
|
const handleDelete = useCallback((item: ComponentItemData | FolderItemData) => {
|
|
const itemName = item.type === 'component' ? item.name : item.name;
|
|
|
|
// Confirm deletion
|
|
const confirmed = confirm(`Are you sure you want to delete "${itemName}"?`);
|
|
if (!confirmed) return;
|
|
|
|
if (item.type === 'component') {
|
|
const component = item.component;
|
|
|
|
UndoQueue.instance.pushAndDo(
|
|
new UndoActionGroup({
|
|
label: `Delete ${component.name}`,
|
|
do: () => {
|
|
ProjectModel.instance.removeComponent(component);
|
|
},
|
|
undo: () => {
|
|
ProjectModel.instance.addComponent(component);
|
|
}
|
|
})
|
|
);
|
|
} else {
|
|
// TODO: Delete folder and all contents
|
|
ToastLayer.showInfo('Folder deletion not yet implemented');
|
|
}
|
|
}, []);
|
|
|
|
const handleDuplicate = useCallback((item: ComponentItemData | FolderItemData) => {
|
|
if (item.type === 'component') {
|
|
const component = item.component;
|
|
const newName = ProjectModel.instance.findUniqueComponentName(component.name + ' Copy');
|
|
|
|
UndoQueue.instance.pushAndDo(
|
|
new UndoActionGroup({
|
|
label: `Duplicate ${component.name}`,
|
|
do: () => {
|
|
const duplicated = ProjectModel.instance.duplicateComponent(component, newName);
|
|
return duplicated;
|
|
},
|
|
undo: (duplicated) => {
|
|
if (duplicated) {
|
|
ProjectModel.instance.removeComponent(duplicated);
|
|
}
|
|
}
|
|
})
|
|
);
|
|
} else {
|
|
// TODO: Duplicate folder and all contents
|
|
ToastLayer.showInfo('Folder duplication not yet implemented');
|
|
}
|
|
}, []);
|
|
|
|
const handleRename = useCallback((item: ComponentItemData | FolderItemData) => {
|
|
// This will be implemented in Phase 5: Inline Rename
|
|
console.log('Rename:', item);
|
|
}, []);
|
|
|
|
return {
|
|
handleMakeHome,
|
|
handleDelete,
|
|
handleDuplicate,
|
|
handleRename
|
|
};
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Step 4: Wire Up Actions
|
|
|
|
### 4.1 Update `ComponentsPanel.tsx`
|
|
|
|
Integrate action handlers:
|
|
|
|
```typescript
|
|
import React, { useCallback, useState } from 'react';
|
|
|
|
import { ComponentTree } from './components/ComponentTree';
|
|
import css from './ComponentsPanel.module.scss';
|
|
import { useComponentActions } from './hooks/useComponentActions';
|
|
import { useComponentsPanel } from './hooks/useComponentsPanel';
|
|
import { ComponentsPanelProps } from './types';
|
|
|
|
export function ComponentsPanel(props: ComponentsPanelProps) {
|
|
const { showSheetList = true, hideSheets = [], componentTitle = 'Components', lockCurrentSheetName } = props;
|
|
|
|
const { treeData, expandedFolders, selectedId, toggleFolder, handleItemClick } = useComponentsPanel({
|
|
hideSheets,
|
|
lockCurrentSheetName
|
|
});
|
|
|
|
const { handleMakeHome, handleDelete, handleDuplicate, handleRename } = useComponentActions();
|
|
|
|
const [addButtonRef, setAddButtonRef] = useState<HTMLButtonElement | null>(null);
|
|
const [showAddMenu, setShowAddMenu] = useState(false);
|
|
|
|
const handleAddButtonClick = useCallback(() => {
|
|
setShowAddMenu(true);
|
|
}, []);
|
|
|
|
return (
|
|
<div className={css.ComponentsPanel}>
|
|
<div className={css.Header}>
|
|
<div className={css.Title}>{componentTitle}</div>
|
|
<button ref={setAddButtonRef} className={css.AddButton} title="Add component" onClick={handleAddButtonClick}>
|
|
<div className={css.AddIcon}>+</div>
|
|
</button>
|
|
</div>
|
|
|
|
{/* ... rest of component ... */}
|
|
|
|
{showAddMenu && addButtonRef && (
|
|
<AddComponentMenu targetElement={addButtonRef} onClose={() => setShowAddMenu(false)} parentPath="" />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Step 5: Testing
|
|
|
|
### 5.1 Verification Checklist
|
|
|
|
- [ ] Header "+" button shows add menu
|
|
- [ ] Add menu includes all component templates
|
|
- [ ] Add menu includes "Folder" option
|
|
- [ ] Right-click on component shows context menu
|
|
- [ ] Right-click on folder shows context menu
|
|
- [ ] "Make Home" action works (and is disabled appropriately)
|
|
- [ ] "Rename" action triggers (implementation in Phase 5)
|
|
- [ ] "Duplicate" action works
|
|
- [ ] "Delete" action works with confirmation
|
|
- [ ] All actions can be undone
|
|
- [ ] All actions can be redone
|
|
- [ ] No console errors
|
|
|
|
### 5.2 Test Edge Cases
|
|
|
|
- [ ] Try to make home on component that can't be home
|
|
- [ ] Try to delete root component (should prevent or handle)
|
|
- [ ] Duplicate component with same name (should auto-rename)
|
|
- [ ] Delete last component in folder
|
|
- [ ] Context menu closes when clicking outside
|
|
|
|
---
|
|
|
|
## Common Issues & Solutions
|
|
|
|
### Issue: Context menu doesn't appear
|
|
|
|
**Solution:** Check that `onContextMenu` handler is attached and `e.preventDefault()` is called.
|
|
|
|
### Issue: Menu appears in wrong position
|
|
|
|
**Solution:** Verify PopupLayer position parameters. Use `{ x: e.clientX, y: e.clientY }` for mouse position.
|
|
|
|
### Issue: Actions don't work
|
|
|
|
**Solution:** Check that ProjectModel methods are being called correctly and UndoQueue integration is proper.
|
|
|
|
### Issue: Undo doesn't work
|
|
|
|
**Solution:** Verify that UndoActionGroup is created correctly with both `do` and `undo` functions.
|
|
|
|
---
|
|
|
|
## Success Criteria
|
|
|
|
✅ **Phase 3 is complete when:**
|
|
|
|
1. Header "+" button shows add menu
|
|
2. All context menus work correctly
|
|
3. Make home action works
|
|
4. Delete action works with confirmation
|
|
5. Duplicate action works
|
|
6. All actions integrate with UndoQueue
|
|
7. Undo/redo works for all actions
|
|
8. No console errors
|
|
|
|
---
|
|
|
|
## Next Phase
|
|
|
|
**Phase 4: Drag-Drop** - Implement drag-drop functionality for reorganizing components and folders.
|