-
Notifications
You must be signed in to change notification settings - Fork 230
Per-Project Tree of Collab Notes #805
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Per-Project Tree of Collab Notes #805
Conversation
Replace the single collab_note TextField with a folder/note tree structure. Notes support collaborative real-time editing via Hocuspocus, folders act as containers. Includes Django model with self-referential FK for hierarchy, Hasura metadata with role-based permissions, frontend tree view with create/rename/delete operations, and data migration for existing notes. Fixes null handling for Hasura bigint fields by using separate queries for root vs child nodes, and adds database-level timestamp defaults for GraphQL inserts.
Use environment variables for host ports in local.yml to avoid conflicts when running multiple instances or on systems where default ports are already in use.
Implement @dnd-kit based drag-and-drop to allow users to reorder notes and folders, and move items into folders. Includes visual drop indicators and keyboard accessibility support. - Add @dnd-kit/core, @dnd-kit/sortable, @dnd-kit/utilities deps - Create useTreeDnd hook for DnD state and handlers - Create SortableTreeItem wrapper component - Update TreeItem with drag/drop visual states - Wrap NoteTreeView with DndContext and SortableContext - Add tree.css for drop indicator styles - Use 1000-gap positions for better reordering room
Increased the nesting indentation from 16px to 20px per depth level to provide clearer visual hierarchy in the collaborative notes tree. Removed the px-2 Bootstrap class that was overriding inline padding styles and replaced it with explicit paddingRight inline style.
- Add ProjectCollabNoteField model for reorderable note content fields - Support rich text and image field types within each note - Add image upload endpoint for note field images - Implement cascade delete for note fields when deleting notes - Add immediate UI feedback for field add/delete operations - Fix delete button not triggering in draggable field editor - Add drag-and-drop reordering for fields within notes - Include migrations for new field model and data migration
Remove CSS transforms from SortableTreeItem since verticalListSortingStrategy causes visual scrambling in hierarchical trees. The DragOverlay now handles the visual preview while items remain stationary.
Both handleAddImage and paste handler were missing the code to update local state and sync to Yjs after image upload. Added setFields and addFieldToYjsDoc calls to both handlers for immediate UI feedback.
The GraphQL query returns raw file paths from the database (e.g., collab_note_images/2026/01/19/file.png) without the /media/ prefix. Prepend /media/ to image paths in the collab server handler so images load correctly from Django's media URL.
Add missing fixed positioning properties to ReactModal overlay style so the Create Folder/Note modal renders centered in viewport instead of at the bottom of the page with buttons cut off.
When users create, delete, rename, or move notes/folders, other users viewing the same project now see updates in real-time without needing to refresh. Uses Hocuspocus stateless messages to broadcast tree change notifications through the existing WebSocket infrastructure.
Prevents accidental data loss by requiring user confirmation before deleting text fields or images from collaborative notes.
Observe the fields Y.Array directly instead of only the meta Y.Map, so changes to the array contents (add/remove) trigger updates for all connected clients viewing the same note.
Use Bootstrap CSS variables instead of hardcoded colors so the toolbar adapts to light/dark mode automatically.
Instead of immediately opening the file picker when clicking "Add Image", users now see a placeholder where they can paste, drag-drop, or click to upload. This provides a better UX and enables real-time sync of the placeholder to other collaborators before the image is uploaded. - Add ImageFieldPlaceholder component with paste/drag-drop/click support - Add createImageField GraphQL mutation for empty image fields - Add uploadToField function to upload images to existing fields - Add ajax_upload_to_existing_field Django endpoint - Update ImageField to render placeholder when image is null - Simplify AddFieldToolbar to just create placeholder on click
Replace browser confirm() dialog with DeleteConfirmModal for tree items. The modal now supports notes, folders, text fields, and images with appropriate messaging. Folders show a warning about deleting children. Also fix recursive deletion of folders with children by fetching all descendants and deleting them depth-first (children before parents) to avoid foreign key constraint violations.
- Add real-time sync for field reordering via Yjs document - Fix GraphQL reorder mutation to use dynamic update_by_pk calls - Update tree item action icons: left-align, larger size, white on selection - Add proper text truncation for long folder/note names
- Add download button in NoteTreeView toolbar to export all notes as zip - Create export_collab_notes_zip view that generates zip with folder structure, markdown files (HTML converted via markdownify), and images - Add debounce to Hocuspocus server for periodic content saves (5s) - Fix save handler to read fields from Y.js meta instead of cached data, ensuring dynamically added fields are properly saved to database
Faster database persistence for better data durability during collaborative editing sessions.
Resolve peer dependency markers and add optional dependencies.
| # Special case: project_tree_sync uses project_id as id | ||
| # and checks if user can access the project | ||
| if model == "project_tree_sync": | ||
| try: | ||
| project = Project.objects.get(id=self.input["id"]) | ||
| except ObjectDoesNotExist: | ||
| return JsonResponse(utils.generate_hasura_error_payload("Not Found", "ModelDoesNotExist"), status=404) | ||
| if not project.user_can_edit(self.user_obj): | ||
| return JsonResponse(utils.generate_hasura_error_payload("Not allowed to edit", "Unauthorized"), status=403) | ||
| return JsonResponse(self.user_obj.username, status=200, safe=False) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add user_can_edit method to ProjectSyncTree instead of special casing
| # Generated migration to add database defaults for timestamps | ||
|
|
||
| from django.db import migrations | ||
|
|
||
|
|
||
| class Migration(migrations.Migration): | ||
| dependencies = [ | ||
| ("rolodex", "0064_migrate_note_content_to_fields"), | ||
| ] | ||
|
|
||
| operations = [ | ||
| migrations.RunSQL( | ||
| sql="ALTER TABLE rolodex_projectcollabnotefield ALTER COLUMN created_at SET DEFAULT NOW();", | ||
| reverse_sql="ALTER TABLE rolodex_projectcollabnotefield ALTER COLUMN created_at DROP DEFAULT;", | ||
| ), | ||
| migrations.RunSQL( | ||
| sql="ALTER TABLE rolodex_projectcollabnotefield ALTER COLUMN updated_at SET DEFAULT NOW();", | ||
| reverse_sql="ALTER TABLE rolodex_projectcollabnotefield ALTER COLUMN updated_at DROP DEFAULT;", | ||
| ), | ||
| ] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is raw sql needed here? Django should have an ORM option for this
| return JsonResponse({"result": "error", "message": "Invalid request method"}, status=405) | ||
|
|
||
| # Import here to avoid circular imports | ||
| from ghostwriter.rolodex.models import ProjectCollabNote, ProjectCollabNoteField |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why is this circular?
(Applies to all API methods in this file that this PR adds)
| except Exception as exception: | ||
| template = "An exception of type {0} occurred. Arguments:\n{1!r}" | ||
| log_message = template.format(type(exception).__name__, exception.args) | ||
| logger.error(log_message) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
log.exception
(Applies to all API methods in this file that this PR adds)
| if 'image' not in request.FILES: | ||
| return JsonResponse({"result": "error", "message": "No image file provided"}, status=400) | ||
|
|
||
| image_file = request.FILES['image'] | ||
|
|
||
| # Validate file type | ||
| allowed_types = ['image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/webp'] | ||
| if image_file.content_type not in allowed_types: | ||
| return JsonResponse( | ||
| {"result": "error", "message": f"Invalid file type: {image_file.content_type}. Allowed types: png, jpg, jpeg, gif, webp"}, | ||
| status=400 | ||
| ) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Use a django form and ImageField
| display: "flex", | ||
| flexDirection: "column", | ||
| alignItems: "center", | ||
| justifyContent: "center", | ||
| padding: "2rem", | ||
| minHeight: "150px", | ||
| border: `2px dashed ${dragActive ? "var(--bs-primary)" : "var(--bs-border-color)"}`, | ||
| borderRadius: "8px", | ||
| backgroundColor: dragActive | ||
| ? "var(--bs-primary-bg-subtle)" | ||
| : "var(--bs-tertiary-bg)", | ||
| cursor: uploading ? "wait" : "pointer", | ||
| transition: "all 0.2s ease", | ||
| outline: "none", |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Use css
| id: noteId.toString(), | ||
| }); | ||
|
|
||
| const [fields, setFields] = useState<NoteField[]>([]); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would be better to use observeDeep and use the yjs doc as the source of truth rather than trying to keep these two in sync
| * When any client modifies the tree structure (create/delete/rename/move), | ||
| * it calls notifyTreeChanged() which broadcasts to all other connected clients, | ||
| * triggering their onTreeChanged callback to refetch the tree. | ||
| */ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Store the tree structure in yjs rather than constantly re-fetching. Even if it's just an array of note values that the frontend arranges into a tree as needed.
| croniter==3.0.3 | ||
| cvss==3.2 | ||
| markdown==3.9 | ||
| markdownify==0.13.1 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we need two markdown packages?
| condition: service_started | ||
| ports: | ||
| - "${HASURA_GRAPHQL_SERVER_PORT}:8080" | ||
| - "${HASURA_GRAPHQL_HOST_PORT:-8080}:8080" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not sure why these are changed
Collaborative Notes Feature - Implementation Document
Branch:
feature-collab-notesDate: January 2026
Commits: 19 commits (f6d6e4e..c367ef3)
1. Features Implemented
1.1 Hierarchical Note Organization
positionfield1.2 Real-Time Collaborative Editing
1.3 Rich Content Support
1.4 Tree Management
1.5 Export Functionality
2. Implementation Details
2.1 Database Models
ProjectCollabNote(ghostwriter/rolodex/models.py:757-837)ProjectCollabNoteField(ghostwriter/rolodex/models.py:847-927)2.2 Database Migrations
ProjectCollabNotemodelProjectCollabNoteFieldmodel2.3 GraphQL/Hasura Layer
New Tables Exposed:
projectCollabNote- Full CRUD with row-level securityprojectCollabNoteField- Full CRUD with row-level securityPermission Model:
Relationships:
projectCollabNote.fields→ array ofprojectCollabNoteFieldprojectCollabNote.children→ self-referential arrayprojectCollabNote.parent→ self-referential objectproject.collabNotes→ array of root-level notes2.4 Collab Server Handlers
project_collab_note.ts- Handles real-time note content editingfield_{id})meta.fields)project_tree_sync.ts- Handles tree structure synchronization2.5 Frontend Components
Component Hierarchy:
Key Hooks:
useNoteTree- Fetches and transforms tree data from GraphQLuseNoteMutations- CRUD operations for notes/foldersuseTreeDnd- Drag-and-drop logic for tree reorganizationuseTreeSync- Real-time tree change notificationsuseFieldMutations- CRUD operations for note fieldsuseImageUpload- Image upload handling (file picker + paste)2.6 Django Views
Image Upload (
views.py:2289-2369)POST /rolodex/ajax/project/{pk}/collab-note/uploadUpload to Existing Field (
views.py:2372-2459)POST /rolodex/ajax/project/{note_pk}/collab-note/field/{field_pk}/uploadZIP Export (
views.py:2463-2545)GET /rolodex/ajax/project/{pk}/notes/export3. Changes Affecting Other Application Parts
3.1 API Views (
ghostwriter/api/views.py)ProjectCollabNotetoCheckEditPermissions.available_modelsproject_tree_syncmodel type3.2 Project Detail Page (
ghostwriter/rolodex/templates/rolodex/project_detail.html)3.3 URL Configuration (
ghostwriter/rolodex/urls.py)3.4 Docker Configuration (
local.yml)POSTGRES_HOST_PORT(default: 5432)NGINX_PORT(default: 8000)HASURA_GRAPHQL_HOST_PORT(default: 8080)3.5 Hasura Metadata
public_rolodex_projectcollabnote.yamlpublic_rolodex_projectcollabnotefield.yamlpublic_rolodex_project.yamlwithcollabNotesrelationshiptables.yamlto include new tables4. Database Connection Dynamics
4.1 When User Opens Collab Notes Tab
Initial Load (GraphQL)
useNoteTreehook fetches tree structure via Apollo ClientprojectCollabNotewith nestedchildrenandfieldsWebSocket Connections Established
project_tree_sync/{projectId}roomproject_collab_note/{noteId}room4.2 During Editing Session
Real-time Content Editing:
Tree Structure Changes (Create/Delete/Rename/Move):
4.3 Connection Lifecycle
4.4 Concurrent User Handling
5. Potential Next Steps
5.1 Feature Enhancements
5.2 Performance Optimizations
5.3 UX Improvements
5.4 Integration
5.5 Technical Debt
contentfield fromProjectCollabNoteafter data migration verified6. File Summary
New Files (30)
Modified Files (8)