feat: Implement node deletion with shared modal and fix SurrealDB RecordId handling
Implements complete node deletion functionality for both galaxy view and debug panel: **Core Changes:** - Created shared DeleteNodeModal component used by both ThoughtGalaxy and UserMenu - Modal provides consistent UX with proper confirmation messaging - Deletion follows write-through cache pattern: ATproto first, then SurrealDB **SurrealDB RecordId Fixes:** - Fixed SELECT query to use type::thing($table, $recordId) for UUID-based RecordIds - Fixed DELETE query to use type::thing() instead of db.delete() to handle dashes in UUIDs - Without type::thing(), SurrealDB interprets dashes as subtraction operators **Testing & Documentation:** - Added comprehensive Magnitude tests for delete functionality (galaxy view and debug panel) - Updated CLAUDE.md with complete testing workflow documentation - Added pre-commit checklist requiring database verification and test execution - Documented PlaywrightMCP manual testing process before Magnitude test writing **Database Setup:** - Configured docker-compose.yml to use environment variables for credentials - Updated namespace/database to match .env configuration (ponderants/main) **File Changes:** - app/api/nodes/[id]/route.ts: Fixed RecordId query patterns (SELECT and DELETE) - components/DeleteNodeModal.tsx: New shared modal component - components/ThoughtGalaxy.tsx: Uses shared DeleteNodeModal - components/UserMenu.tsx: Replaced browser confirm() with shared DeleteNodeModal - tests/magnitude/03-delete-node.mag.ts: Added debug panel delete test - AGENTS.md: Added testing workflow and pre-commit checklist documentation - docker-compose.yml: Environment variable configuration 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
100
AGENTS.md
100
AGENTS.md
@@ -17,6 +17,106 @@ EOF
|
||||
|
||||
These credentials should be used for all automated testing (Magnitude, Playwright) and manual testing when needed. Do not attempt to authenticate without using these credentials.
|
||||
|
||||
**Database Setup**: The application uses SurrealDB running in Docker Compose for the app cache layer:
|
||||
|
||||
1. Start the database services:
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
2. This starts two services:
|
||||
- `surrealdb`: The main SurrealDB instance (port 8000)
|
||||
- `surrealmcp`: SurrealMCP server for MCP access (port 8080)
|
||||
|
||||
3. Start the Next.js development server:
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
4. To stop the services:
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
|
||||
5. Configuration:
|
||||
- SurrealDB runs in-memory mode (data is not persisted between restarts)
|
||||
- Namespace: `ponderants`
|
||||
- Database: `main`
|
||||
- Credentials: `root/root`
|
||||
|
||||
**Note**: Always start docker compose services before starting the Next.js dev server to ensure the database is available.
|
||||
|
||||
**Testing Workflow**: All new features must follow a rigorous testing process before being committed:
|
||||
|
||||
1. **Manual Testing with Playwright MCP**:
|
||||
- Use Playwright MCP tools to manually test all functionality interactively
|
||||
- Test both happy paths (expected user flows) and unhappy paths (errors, edge cases)
|
||||
- Document each step you verify during manual testing - these become test cases
|
||||
- If you encounter issues during manual testing (e.g., 404 errors, unexpected behavior), investigate and fix them before proceeding
|
||||
- Use the following pattern:
|
||||
```
|
||||
1. Navigate to the feature
|
||||
2. Perform user actions (clicks, typing, etc.)
|
||||
3. Verify expected outcomes
|
||||
4. Test error scenarios
|
||||
5. Verify cleanup/state updates
|
||||
```
|
||||
|
||||
2. **Write Comprehensive Magnitude Tests**:
|
||||
- After manually verifying functionality with Playwright MCP, write extensive Magnitude tests covering ALL verified behaviors
|
||||
- Each manual test step should have a corresponding Magnitude test assertion
|
||||
- Test files are located in `tests/magnitude/` with `.mag.ts` extension
|
||||
- Use the test credentials from .env (TEST_BLUESKY_HANDLE, TEST_BLUESKY_PASSWORD)
|
||||
- Include both happy path and unhappy path test cases
|
||||
- Example test structure:
|
||||
```typescript
|
||||
import { test } from 'magnitude-test';
|
||||
|
||||
const TEST_HANDLE = process.env.TEST_BLUESKY_HANDLE;
|
||||
const TEST_PASSWORD = process.env.TEST_BLUESKY_PASSWORD;
|
||||
|
||||
test('Feature description', async (agent) => {
|
||||
await agent.act('Navigate to /page');
|
||||
await agent.act('Perform user action');
|
||||
await agent.check('Verify expected outcome');
|
||||
});
|
||||
```
|
||||
|
||||
3. **Reusable Playwright Scaffolding**:
|
||||
- Abstract common patterns (auth, navigation, etc.) into helper files in `tests/playwright/helpers/`
|
||||
- These helpers should be usable both during manual Playwright MCP testing AND by Magnitude tests
|
||||
- Examples: `tests/playwright/helpers/chat.ts`, `tests/playwright/helpers/galaxy.ts`, `tests/playwright/helpers/node.ts`
|
||||
- For auth setup, use Playwright's global setup pattern (see https://playwright.dev/docs/test-global-setup-teardown)
|
||||
- Current auth setup: `tests/playwright/auth.setup.ts`
|
||||
|
||||
4. **Generating Playwright Code**:
|
||||
- Use https://playwright.dev/docs/test-agents to generate Playwright test code when helpful
|
||||
- This tool can convert natural language test descriptions into Playwright code
|
||||
|
||||
5. **Test Execution**:
|
||||
- Run Magnitude tests: `pnpm test` or `npx magnitude`
|
||||
- Ensure ALL tests pass before committing
|
||||
- If tests fail, fix the implementation or update the tests to match the correct behavior
|
||||
|
||||
6. **Pre-Commit Checklist**:
|
||||
- ✅ All manual testing with Playwright MCP completed and verified
|
||||
- ✅ All Magnitude tests written and cover all verified functionality
|
||||
- ✅ Database verified for expected state after operations (e.g., deletions actually removed records)
|
||||
- ✅ Run ALL magnitude tests: `pnpm test`
|
||||
- ✅ All tests passing
|
||||
- ✅ No console errors or warnings in production code paths
|
||||
- Only commit after ALL checklist items are complete
|
||||
|
||||
7. **Documentation**:
|
||||
- Document test coverage in `tests/README.md`
|
||||
- Add comments to complex test scenarios explaining the business logic being tested
|
||||
|
||||
**Testing Resources**:
|
||||
- Playwright Global Setup/Teardown: https://playwright.dev/docs/test-global-setup-teardown
|
||||
- Playwright Test Agents: https://playwright.dev/docs/test-agents
|
||||
- Magnitude.run Documentation: https://magnitude.run/docs
|
||||
- Project Test README: `tests/README.md`
|
||||
|
||||
You are an expert-level, full-stack AI coding agent. Your task is to implement
|
||||
the "Ponderants" application. Product Vision: Ponderants is an AI-powered
|
||||
thought partner that interviews a user to capture, structure, and visualize
|
||||
|
||||
@@ -50,9 +50,13 @@ export async function DELETE(
|
||||
// 1. Fetch the node from SurrealDB to verify ownership and get atp_uri
|
||||
const db = await connectToDB();
|
||||
|
||||
// Parse the ID to extract table and record ID parts
|
||||
// Format: "node:e9e38d09-c0f4-4834-a6ba-c92dfa4c0910" or "node:⟨e9e38d09-c0f4-4834-a6ba-c92dfa4c0910⟩"
|
||||
const cleanId = id.replace(/^node:/, '').replace(/[⟨⟩]/g, '');
|
||||
|
||||
const nodeResult = await db.query<[Array<{ id: string; user_did: string; atp_uri: string }>]>(
|
||||
'SELECT id, user_did, atp_uri FROM node WHERE id = $nodeId',
|
||||
{ nodeId: id }
|
||||
'SELECT id, user_did, atp_uri FROM node WHERE id = type::thing($table, $recordId)',
|
||||
{ table: 'node', recordId: cleanId }
|
||||
);
|
||||
|
||||
const node = nodeResult[0]?.[0];
|
||||
@@ -114,7 +118,11 @@ export async function DELETE(
|
||||
|
||||
// 4. Delete from SurrealDB cache (only after successful ATproto deletion)
|
||||
try {
|
||||
await db.delete(id);
|
||||
// Use type::thing() to properly construct the RecordId for deletion
|
||||
await db.query('DELETE FROM type::thing($table, $recordId)', {
|
||||
table: 'node',
|
||||
recordId: cleanId,
|
||||
});
|
||||
console.log('[DELETE /api/nodes/[id]] ✓ Deleted node from SurrealDB cache');
|
||||
} catch (error) {
|
||||
console.warn('[DELETE /api/nodes/[id]] ⚠ SurrealDB cache deletion failed (non-critical):', error);
|
||||
|
||||
58
components/DeleteNodeModal.tsx
Normal file
58
components/DeleteNodeModal.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
'use client';
|
||||
|
||||
import { Modal, Stack, Text, Group, Button } from '@mantine/core';
|
||||
import { IconTrash } from '@tabler/icons-react';
|
||||
|
||||
interface DeleteNodeModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
nodeTitle: string | null;
|
||||
isDeleting: boolean;
|
||||
}
|
||||
|
||||
export function DeleteNodeModal({
|
||||
opened,
|
||||
onClose,
|
||||
onConfirm,
|
||||
nodeTitle,
|
||||
isDeleting,
|
||||
}: DeleteNodeModalProps) {
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title="Delete Node"
|
||||
centered
|
||||
zIndex={1001}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text>
|
||||
Are you sure you want to delete "{nodeTitle}"? This will:
|
||||
</Text>
|
||||
<Stack gap="xs" ml="md">
|
||||
<Text size="sm">• Remove the post from Bluesky</Text>
|
||||
<Text size="sm">• Delete the node from your galaxy</Text>
|
||||
<Text size="sm" fw={600} c="red">This action cannot be undone.</Text>
|
||||
</Stack>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={onClose}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
onClick={onConfirm}
|
||||
loading={isDeleting}
|
||||
leftSection={<IconTrash size={16} />}
|
||||
>
|
||||
Delete Permanently
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -7,9 +7,10 @@ import {
|
||||
Text,
|
||||
} from '@react-three/drei';
|
||||
import { Suspense, useEffect, useRef, useState } from 'react';
|
||||
import { Stack, Text as MantineText, Paper, Title, Box, CloseButton, Group, Anchor, useComputedColorScheme, Button, Modal } from '@mantine/core';
|
||||
import { Stack, Text as MantineText, Paper, Title, Box, CloseButton, Group, Anchor, useComputedColorScheme, Button } from '@mantine/core';
|
||||
import { IconTrash } from '@tabler/icons-react';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { DeleteNodeModal } from './DeleteNodeModal';
|
||||
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
|
||||
import * as THREE from 'three';
|
||||
|
||||
@@ -482,41 +483,13 @@ export function ThoughtGalaxy() {
|
||||
)}
|
||||
|
||||
{/* Delete confirmation modal */}
|
||||
<Modal
|
||||
<DeleteNodeModal
|
||||
opened={deleteConfirmOpen}
|
||||
onClose={() => setDeleteConfirmOpen(false)}
|
||||
title="Delete Node"
|
||||
centered
|
||||
zIndex={1001}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<MantineText>
|
||||
Are you sure you want to delete this node? This will:
|
||||
</MantineText>
|
||||
<Stack gap="xs" ml="md">
|
||||
<MantineText size="sm">• Remove the post from Bluesky</MantineText>
|
||||
<MantineText size="sm">• Delete the node from your galaxy</MantineText>
|
||||
<MantineText size="sm" fw={600} c="red">This action cannot be undone.</MantineText>
|
||||
</Stack>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => setDeleteConfirmOpen(false)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
onClick={handleDeleteNode}
|
||||
loading={isDeleting}
|
||||
leftSection={<IconTrash size={16} />}
|
||||
>
|
||||
Delete Permanently
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
onConfirm={handleDeleteNode}
|
||||
nodeTitle={selectedNode?.title || null}
|
||||
isDeleting={isDeleting}
|
||||
/>
|
||||
|
||||
<Canvas
|
||||
camera={{ position: [0, 5, 10], fov: 60 }}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useMantineColorScheme } from '@mantine/core';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { IconSun, IconMoon, IconDeviceDesktop } from '@tabler/icons-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { DeleteNodeModal } from './DeleteNodeModal';
|
||||
|
||||
interface UserProfile {
|
||||
did: string;
|
||||
@@ -28,6 +29,8 @@ export function UserMenu({ showLabel = false }: { showLabel?: boolean } = {}) {
|
||||
const [nodes, setNodes] = useState<Node[]>([]);
|
||||
const [nodesLoading, setNodesLoading] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
const [nodeToDelete, setNodeToDelete] = useState<Node | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch user profile on mount
|
||||
@@ -65,12 +68,15 @@ export function UserMenu({ showLabel = false }: { showLabel?: boolean } = {}) {
|
||||
};
|
||||
|
||||
// Delete a node (debug) - Matches ThoughtGalaxy delete pattern
|
||||
const handleDebugDelete = async (nodeId: string) => {
|
||||
const handleDebugDelete = async () => {
|
||||
if (!nodeToDelete) return;
|
||||
|
||||
setIsDeleting(true);
|
||||
setDeleteConfirmOpen(false);
|
||||
|
||||
try {
|
||||
// Extract clean ID from SurrealDB RecordId format (removes angle brackets ⟨⟩)
|
||||
const cleanId = String(nodeId).replace(/[⟨⟩]/g, '');
|
||||
const cleanId = String(nodeToDelete.id).replace(/[⟨⟩]/g, '');
|
||||
|
||||
const response = await fetch(`/api/nodes/${cleanId}`, {
|
||||
method: 'DELETE',
|
||||
@@ -89,7 +95,8 @@ export function UserMenu({ showLabel = false }: { showLabel?: boolean } = {}) {
|
||||
});
|
||||
|
||||
// Update local state to remove the deleted node
|
||||
setNodes((prevNodes) => prevNodes.filter((n) => n.id !== nodeId));
|
||||
setNodes((prevNodes) => prevNodes.filter((n) => n.id !== nodeToDelete.id));
|
||||
setNodeToDelete(null);
|
||||
} catch (error) {
|
||||
console.error('[UserMenu Debug] Delete error:', error);
|
||||
notifications.show({
|
||||
@@ -269,9 +276,8 @@ export function UserMenu({ showLabel = false }: { showLabel?: boolean } = {}) {
|
||||
<Text size="xs" fw={600}>{node.title}</Text>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm(`Delete "${node.title}"?`)) {
|
||||
handleDebugDelete(node.id);
|
||||
}
|
||||
setNodeToDelete(node);
|
||||
setDeleteConfirmOpen(true);
|
||||
}}
|
||||
style={{
|
||||
background: '#fa5252',
|
||||
@@ -282,6 +288,7 @@ export function UserMenu({ showLabel = false }: { showLabel?: boolean } = {}) {
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.65rem',
|
||||
}}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
@@ -306,6 +313,15 @@ export function UserMenu({ showLabel = false }: { showLabel?: boolean } = {}) {
|
||||
Log out
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
|
||||
{/* Delete confirmation modal - shared with ThoughtGalaxy */}
|
||||
<DeleteNodeModal
|
||||
opened={deleteConfirmOpen}
|
||||
onClose={() => setDeleteConfirmOpen(false)}
|
||||
onConfirm={handleDebugDelete}
|
||||
nodeTitle={nodeToDelete?.title || null}
|
||||
isDeleting={isDeleting}
|
||||
/>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,12 +8,27 @@ services:
|
||||
- --log
|
||||
- trace
|
||||
- --user
|
||||
- root
|
||||
- ${SURREALDB_USER}
|
||||
- --pass
|
||||
- root
|
||||
- ${SURREALDB_PASS}
|
||||
- memory
|
||||
volumes:
|
||||
- ./surreal/data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
surrealmcp:
|
||||
image: surrealdb/surrealmcp:latest
|
||||
command: >
|
||||
start
|
||||
--bind-address 0.0.0.0:8080
|
||||
--server-url http://localhost:8080
|
||||
-e ws://surrealdb:8000/rpc
|
||||
--ns ${SURREALDB_NS} --db ${SURREALDB_DB} -u ${SURREALDB_USER} -p ${SURREALDB_PASS}
|
||||
ports:
|
||||
- "8080:8080"
|
||||
depends_on:
|
||||
- surrealdb
|
||||
|
||||
@@ -135,3 +135,70 @@ test('Node deletion removes associated links', async (agent) => {
|
||||
await agent.check('The link between the nodes is no longer visible');
|
||||
await agent.check('Only one node remains in the galaxy');
|
||||
});
|
||||
|
||||
test('User can delete node from debug panel in Profile menu', async (agent) => {
|
||||
// Act: Log in
|
||||
await agent.act('Navigate to /login');
|
||||
await agent.act(`Type "${TEST_HANDLE}" into the "Your Handle" input field`);
|
||||
await agent.act('Click the "Log in with Bluesky" button');
|
||||
await agent.check('The page URL contains "bsky.social"');
|
||||
await agent.act(`Type "${TEST_HANDLE}" into the username/identifier field`);
|
||||
await agent.act(`Type "${TEST_PASSWORD}" into the password field`);
|
||||
await agent.act('Click the submit/authorize button');
|
||||
await agent.check('The page URL contains "/chat"');
|
||||
|
||||
// Act: Create a test node via chat
|
||||
await agent.act('Type "Test node for debug panel deletion" into the chat input');
|
||||
await agent.act('Press Enter or click send');
|
||||
await agent.check('AI responds with a message');
|
||||
|
||||
// Act: Trigger node creation and publish
|
||||
await agent.act('Wait for the AI to suggest creating a node or manually trigger node creation');
|
||||
await agent.check('A node draft is created in the editor');
|
||||
await agent.act('Click the "Publish" button');
|
||||
await agent.check('A success notification appears');
|
||||
|
||||
// Act: Open Profile menu
|
||||
await agent.act('Click the "Profile" button in the navigation sidebar');
|
||||
await agent.check('The Profile menu opens');
|
||||
|
||||
// Check: Verify debug panel is visible (development mode only)
|
||||
await agent.check('A "Debug: SurrealDB Nodes" section is visible');
|
||||
|
||||
// Act: Fetch nodes from debug panel
|
||||
await agent.act('Click the "Fetch Nodes" button');
|
||||
await agent.check('The button shows a count greater than 0');
|
||||
await agent.check('At least one node is listed in the debug panel');
|
||||
|
||||
// Check: Verify the test node appears
|
||||
await agent.check('The node "Test node for debug panel deletion" is visible');
|
||||
|
||||
// Act: Click delete button in debug panel
|
||||
await agent.act('Click the "Delete" button next to the test node');
|
||||
|
||||
// Check: Verify delete confirmation modal appears
|
||||
await agent.check('A delete confirmation modal appears');
|
||||
await agent.check('The modal is displayed above the profile menu');
|
||||
await agent.check('The modal shows the node title "Test node for debug panel deletion"');
|
||||
await agent.check('The modal explains this will remove the post from Bluesky');
|
||||
await agent.check('The modal shows "This action cannot be undone"');
|
||||
await agent.check('The modal has a "Delete Permanently" button');
|
||||
await agent.check('The modal has a "Cancel" button');
|
||||
|
||||
// Act: Confirm deletion
|
||||
await agent.act('Click the "Delete Permanently" button');
|
||||
|
||||
// Check: Verify deletion succeeded
|
||||
await agent.check('A success notification appears saying "Node deleted"');
|
||||
await agent.check('The notification says "Node has been deleted from Bluesky and your galaxy"');
|
||||
await agent.check('The modal closes');
|
||||
|
||||
// Check: Verify node is removed from debug panel
|
||||
await agent.check('The "Fetch Nodes" button shows a count of 0 or the node is no longer in the list');
|
||||
|
||||
// Act: Verify node is deleted from Bluesky and database
|
||||
await agent.act('Refresh the page');
|
||||
await agent.act('Click the "Profile" button again');
|
||||
await agent.act('Click the "Fetch Nodes" button');
|
||||
await agent.check('The node "Test node for debug panel deletion" is not in the list');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user