From 9e99f3c216b4c7aa29dc34403ec910d7e13c07ef Mon Sep 17 00:00:00 2001
From: BlaiseOfGlory <5067183+BlaiseOfGlory@users.noreply.github.com>
Date: Tue, 31 Mar 2026 13:26:37 -0500
Subject: [PATCH 01/18] Add per-project hierarchical collaborative notes
Reimplements the collab notes feature addressing all review feedback
from PR #805. Key changes from the original submission:
Backend:
- ProjectCollabNote and ProjectCollabNoteField models with proper
Django conventions (default=timezone.now, no raw SQL migrations)
- Image upload uses Django Form with ImageField for validation
- Merged upload views into single endpoint with optional field_pk
- ZIP export uses StreamingHttpResponse instead of reading to memory
- Exception handlers use logger.exception() throughout
- Model imports at module level (no circular import workarounds)
- CheckEditPermissions uses available_models dict (no special case)
Frontend:
- NoteEditor derives field state from Yjs doc via observeDeep
(single source of truth, no manual React state sync)
- Tree structure stored in Yjs Y.Array for real-time sync across
clients (replaces stateless message broadcast + GraphQL re-fetch)
- DeleteConfirmModal uses top-level const map (not closure)
- Components use CSS classes instead of extensive inline styles
- ClipboardEvent typed directly (no casting)
- MIME checks use startsWith("image/") not indexOf
Includes migrations, Hasura metadata, collab server handlers,
factories, and 17 test cases.
---
ghostwriter/api/views.py | 3 +
ghostwriter/factories.py | 26 ++
...rojectcollabnote_projectcollabnotefield.py | 194 +++++++++++
.../migrations/0062_migrate_collab_notes.py | 39 +++
ghostwriter/rolodex/models.py | 174 ++++++++++
ghostwriter/rolodex/tests/test_models.py | 89 +++++-
ghostwriter/rolodex/tests/test_views.py | 114 +++++++
ghostwriter/rolodex/urls.py | 15 +
ghostwriter/rolodex/views.py | 191 ++++++++++-
.../tables/public_rolodex_project.yaml | 7 +
.../public_rolodex_projectcollabnote.yaml | 163 ++++++++++
...public_rolodex_projectcollabnotefield.yaml | 145 +++++++++
.../databases/default/tables/tables.yaml | 2 +
.../handlers/project_collab_note.ts | 135 ++++++++
.../handlers/project_tree_sync.ts | 54 ++++
javascript/src/collab_server/index.ts | 11 +
.../collab_forms/forms/project_collabnote.tsx | 52 ---
.../project_collabnotes/AddFieldToolbar.tsx | 28 ++
.../forms/project_collabnotes/CreateModal.tsx | 93 ++++++
.../DeleteConfirmModal.tsx | 80 +++++
.../forms/project_collabnotes/ImageField.tsx | 61 ++++
.../ImageFieldPlaceholder.tsx | 118 +++++++
.../forms/project_collabnotes/NoteEditor.tsx | 301 ++++++++++++++++++
.../project_collabnotes/NoteFieldEditor.tsx | 87 +++++
.../project_collabnotes/NoteTreeView.tsx | 301 ++++++++++++++++++
.../project_collabnotes/SortableTreeItem.tsx | 74 +++++
.../forms/project_collabnotes/TreeItem.tsx | 182 +++++++++++
.../hooks/useFieldMutations.ts | 162 ++++++++++
.../hooks/useImageUpload.ts | 132 ++++++++
.../hooks/useNoteMutations.ts | 258 +++++++++++++++
.../project_collabnotes/hooks/useTreeDnd.ts | 153 +++++++++
.../project_collabnotes/hooks/useTreeSync.ts | 186 +++++++++++
.../forms/project_collabnotes/index.tsx | 86 +++++
.../forms/project_collabnotes/tree.css | 132 ++++++++
.../forms/project_collabnotes/types.ts | 32 ++
javascript/vite.config.frontend.ts | 2 +-
requirements/base.txt | 1 +
37 files changed, 3828 insertions(+), 55 deletions(-)
create mode 100644 ghostwriter/rolodex/migrations/0061_projectcollabnote_projectcollabnotefield.py
create mode 100644 ghostwriter/rolodex/migrations/0062_migrate_collab_notes.py
create mode 100644 hasura-docker/metadata/databases/default/tables/public_rolodex_projectcollabnote.yaml
create mode 100644 hasura-docker/metadata/databases/default/tables/public_rolodex_projectcollabnotefield.yaml
create mode 100644 javascript/src/collab_server/handlers/project_collab_note.ts
create mode 100644 javascript/src/collab_server/handlers/project_tree_sync.ts
delete mode 100644 javascript/src/frontend/collab_forms/forms/project_collabnote.tsx
create mode 100644 javascript/src/frontend/collab_forms/forms/project_collabnotes/AddFieldToolbar.tsx
create mode 100644 javascript/src/frontend/collab_forms/forms/project_collabnotes/CreateModal.tsx
create mode 100644 javascript/src/frontend/collab_forms/forms/project_collabnotes/DeleteConfirmModal.tsx
create mode 100644 javascript/src/frontend/collab_forms/forms/project_collabnotes/ImageField.tsx
create mode 100644 javascript/src/frontend/collab_forms/forms/project_collabnotes/ImageFieldPlaceholder.tsx
create mode 100644 javascript/src/frontend/collab_forms/forms/project_collabnotes/NoteEditor.tsx
create mode 100644 javascript/src/frontend/collab_forms/forms/project_collabnotes/NoteFieldEditor.tsx
create mode 100644 javascript/src/frontend/collab_forms/forms/project_collabnotes/NoteTreeView.tsx
create mode 100644 javascript/src/frontend/collab_forms/forms/project_collabnotes/SortableTreeItem.tsx
create mode 100644 javascript/src/frontend/collab_forms/forms/project_collabnotes/TreeItem.tsx
create mode 100644 javascript/src/frontend/collab_forms/forms/project_collabnotes/hooks/useFieldMutations.ts
create mode 100644 javascript/src/frontend/collab_forms/forms/project_collabnotes/hooks/useImageUpload.ts
create mode 100644 javascript/src/frontend/collab_forms/forms/project_collabnotes/hooks/useNoteMutations.ts
create mode 100644 javascript/src/frontend/collab_forms/forms/project_collabnotes/hooks/useTreeDnd.ts
create mode 100644 javascript/src/frontend/collab_forms/forms/project_collabnotes/hooks/useTreeSync.ts
create mode 100644 javascript/src/frontend/collab_forms/forms/project_collabnotes/index.tsx
create mode 100644 javascript/src/frontend/collab_forms/forms/project_collabnotes/tree.css
create mode 100644 javascript/src/frontend/collab_forms/forms/project_collabnotes/types.ts
diff --git a/ghostwriter/api/views.py b/ghostwriter/api/views.py
index d80ab7514..fa338c9dc 100644
--- a/ghostwriter/api/views.py
+++ b/ghostwriter/api/views.py
@@ -53,6 +53,7 @@
from ghostwriter.reporting.views2.report_finding_link import get_position
from ghostwriter.rolodex.models import (
Project,
+ ProjectCollabNote,
ProjectContact,
ProjectObjective,
ProjectSubTask,
@@ -1373,6 +1374,8 @@ class CheckEditPermissions(JwtRequiredMixin, HasuraActionView):
"report_finding_link": ReportFindingLink,
"report": Report,
"project": Project,
+ "project_collab_note": ProjectCollabNote,
+ "project_tree_sync": Project,
}
def post(self, request):
diff --git a/ghostwriter/factories.py b/ghostwriter/factories.py
index e73d8a512..51d07be04 100644
--- a/ghostwriter/factories.py
+++ b/ghostwriter/factories.py
@@ -578,6 +578,32 @@ class Meta:
operator = factory.SubFactory(UserFactory)
+class ProjectCollabNoteFactory(factory.django.DjangoModelFactory):
+ class Meta:
+ model = "rolodex.ProjectCollabNote"
+
+ title = Faker("sentence", nb_words=3)
+ node_type = "note"
+ content = Faker("rich_text")
+ position = factory.Sequence(lambda n: n * 1000)
+ project = factory.SubFactory(ProjectFactory)
+
+
+class ProjectCollabNoteFolderFactory(ProjectCollabNoteFactory):
+ node_type = "folder"
+ content = ""
+
+
+class ProjectCollabNoteFieldFactory(factory.django.DjangoModelFactory):
+ class Meta:
+ model = "rolodex.ProjectCollabNoteField"
+
+ field_type = "rich_text"
+ content = Faker("rich_text")
+ position = factory.Sequence(lambda n: n)
+ note = factory.SubFactory(ProjectCollabNoteFactory)
+
+
class ClientInviteFactory(factory.django.DjangoModelFactory):
class Meta:
model = "rolodex.ClientInvite"
diff --git a/ghostwriter/rolodex/migrations/0061_projectcollabnote_projectcollabnotefield.py b/ghostwriter/rolodex/migrations/0061_projectcollabnote_projectcollabnotefield.py
new file mode 100644
index 000000000..a4902bbe5
--- /dev/null
+++ b/ghostwriter/rolodex/migrations/0061_projectcollabnote_projectcollabnotefield.py
@@ -0,0 +1,194 @@
+import django.db.models.deletion
+import django.utils.timezone
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("rolodex", "0060_alter_clientcontact_options_and_more"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="ProjectCollabNote",
+ fields=[
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "title",
+ models.CharField(
+ help_text="Title of the note or folder",
+ max_length=255,
+ verbose_name="Title",
+ ),
+ ),
+ (
+ "node_type",
+ models.CharField(
+ choices=[("folder", "Folder"), ("note", "Note")],
+ default="note",
+ help_text="Whether this is a folder or a note",
+ max_length=10,
+ verbose_name="Type",
+ ),
+ ),
+ (
+ "content",
+ models.TextField(
+ blank=True,
+ default="",
+ help_text="Rich text content (for notes only, empty for folders)",
+ verbose_name="Content",
+ ),
+ ),
+ (
+ "position",
+ models.PositiveIntegerField(
+ default=0,
+ help_text="Order within parent (lower values first)",
+ verbose_name="Position",
+ ),
+ ),
+ (
+ "created_at",
+ models.DateTimeField(default=django.utils.timezone.now),
+ ),
+ (
+ "updated_at",
+ models.DateTimeField(default=django.utils.timezone.now),
+ ),
+ (
+ "parent",
+ models.ForeignKey(
+ blank=True,
+ help_text="Parent folder (null for root-level items)",
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="children",
+ to="rolodex.projectcollabnote",
+ ),
+ ),
+ (
+ "project",
+ models.ForeignKey(
+ help_text="The project this note belongs to",
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="collab_notes",
+ to="rolodex.project",
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "Project collaborative note",
+ "verbose_name_plural": "Project collaborative notes",
+ "ordering": ["position", "title"],
+ },
+ ),
+ migrations.AddConstraint(
+ model_name="projectcollabnote",
+ constraint=models.CheckConstraint(
+ check=models.Q(("node_type", "note"), ("content", ""), _connector="OR"),
+ name="folder_has_no_content",
+ ),
+ ),
+ migrations.CreateModel(
+ name="ProjectCollabNoteField",
+ fields=[
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "field_type",
+ models.CharField(
+ choices=[("rich_text", "Rich Text"), ("image", "Image")],
+ default="rich_text",
+ help_text="Type of content in this field",
+ max_length=10,
+ verbose_name="Field Type",
+ ),
+ ),
+ (
+ "content",
+ models.TextField(
+ blank=True,
+ default="",
+ help_text="HTML content for rich text fields",
+ verbose_name="Content",
+ ),
+ ),
+ (
+ "image_width",
+ models.IntegerField(
+ blank=True,
+ editable=False,
+ null=True,
+ verbose_name="Image Width",
+ ),
+ ),
+ (
+ "image_height",
+ models.IntegerField(
+ blank=True,
+ editable=False,
+ null=True,
+ verbose_name="Image Height",
+ ),
+ ),
+ (
+ "image",
+ models.ImageField(
+ blank=True,
+ help_text="Image file for image fields",
+ height_field="image_height",
+ null=True,
+ upload_to="collab_note_images/%Y/%m/%d/",
+ verbose_name="Image",
+ width_field="image_width",
+ ),
+ ),
+ (
+ "position",
+ models.PositiveIntegerField(
+ default=0,
+ help_text="Order within note (lower values first)",
+ verbose_name="Position",
+ ),
+ ),
+ (
+ "created_at",
+ models.DateTimeField(default=django.utils.timezone.now),
+ ),
+ (
+ "updated_at",
+ models.DateTimeField(default=django.utils.timezone.now),
+ ),
+ (
+ "note",
+ models.ForeignKey(
+ help_text="The note this field belongs to",
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="fields",
+ to="rolodex.projectcollabnote",
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "Project collaborative note field",
+ "verbose_name_plural": "Project collaborative note fields",
+ "ordering": ["note", "position"],
+ },
+ ),
+ ]
diff --git a/ghostwriter/rolodex/migrations/0062_migrate_collab_notes.py b/ghostwriter/rolodex/migrations/0062_migrate_collab_notes.py
new file mode 100644
index 000000000..8eafa84f9
--- /dev/null
+++ b/ghostwriter/rolodex/migrations/0062_migrate_collab_notes.py
@@ -0,0 +1,39 @@
+"""Data migration to move content from Project.collab_note to ProjectCollabNote."""
+
+from django.db import migrations
+
+
+def migrate_collab_notes(apps, schema_editor):
+ Project = apps.get_model("rolodex", "Project")
+ ProjectCollabNote = apps.get_model("rolodex", "ProjectCollabNote")
+ ProjectCollabNoteField = apps.get_model("rolodex", "ProjectCollabNoteField")
+
+ for project in Project.objects.exclude(collab_note=""):
+ note = ProjectCollabNote.objects.create(
+ project=project,
+ title="Notes",
+ node_type="note",
+ content=project.collab_note,
+ position=0,
+ )
+ ProjectCollabNoteField.objects.create(
+ note=note,
+ field_type="rich_text",
+ content=project.collab_note,
+ position=0,
+ )
+
+
+def reverse_migrate(apps, schema_editor):
+ ProjectCollabNote = apps.get_model("rolodex", "ProjectCollabNote")
+ ProjectCollabNote.objects.all().delete()
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("rolodex", "0061_projectcollabnote_projectcollabnotefield"),
+ ]
+
+ operations = [
+ migrations.RunPython(migrate_collab_notes, reverse_migrate),
+ ]
diff --git a/ghostwriter/rolodex/models.py b/ghostwriter/rolodex/models.py
index 1dd40114d..9dd5f5bc4 100644
--- a/ghostwriter/rolodex/models.py
+++ b/ghostwriter/rolodex/models.py
@@ -3,6 +3,8 @@
# Standard Libraries
from datetime import time, timedelta
+from django.utils import timezone
+
# Django Imports
from django.conf import settings
from django.contrib.auth import get_user_model
@@ -752,6 +754,178 @@ def __str__(self):
return f"{self.project}: {self.timestamp} - {self.note}"
+class ProjectCollabNoteType(models.TextChoices):
+ """Choices for the type of collaborative note node."""
+
+ FOLDER = "folder", "Folder"
+ NOTE = "note", "Note"
+
+
+class ProjectCollabNote(models.Model):
+ """
+ Stores hierarchical collaborative notes for a project.
+
+ Folders are containers only; notes are leaf nodes with rich text content.
+ Related to :model:`rolodex.Project`.
+ """
+
+ project = models.ForeignKey(
+ Project,
+ on_delete=models.CASCADE,
+ related_name="collab_notes",
+ help_text="The project this note belongs to",
+ )
+ parent = models.ForeignKey(
+ "self",
+ on_delete=models.CASCADE,
+ null=True,
+ blank=True,
+ related_name="children",
+ help_text="Parent folder (null for root-level items)",
+ )
+ title = models.CharField(
+ "Title",
+ max_length=255,
+ help_text="Title of the note or folder",
+ )
+ node_type = models.CharField(
+ "Type",
+ max_length=10,
+ choices=ProjectCollabNoteType.choices,
+ default=ProjectCollabNoteType.NOTE,
+ help_text="Whether this is a folder or a note",
+ )
+ content = models.TextField(
+ "Content",
+ default="",
+ blank=True,
+ help_text="Rich text content (for notes only, empty for folders)",
+ )
+ position = models.PositiveIntegerField(
+ "Position",
+ default=0,
+ help_text="Order within parent (lower values first)",
+ )
+ created_at = models.DateTimeField(default=timezone.now)
+ updated_at = models.DateTimeField(default=timezone.now)
+
+ class Meta:
+ ordering = ["position", "title"]
+ verbose_name = "Project collaborative note"
+ verbose_name_plural = "Project collaborative notes"
+ constraints = [
+ models.CheckConstraint(
+ check=Q(node_type="note") | Q(content=""),
+ name="folder_has_no_content",
+ ),
+ ]
+
+ def __str__(self):
+ return f"{self.title} ({self.node_type})"
+
+ def get_absolute_url(self):
+ return reverse("rolodex:project_detail", args=[str(self.project.id)])
+
+ def save(self, *args, **kwargs):
+ self.updated_at = timezone.now()
+ super().save(*args, **kwargs)
+
+ def user_can_view(self, user) -> bool:
+ return self.project.user_can_view(user)
+
+ def user_can_edit(self, user) -> bool:
+ return self.project.user_can_edit(user)
+
+ def user_can_delete(self, user) -> bool:
+ return self.project.user_can_delete(user)
+
+
+class ProjectCollabNoteFieldType(models.TextChoices):
+ """Choices for the type of collaborative note field."""
+
+ RICH_TEXT = "rich_text", "Rich Text"
+ IMAGE = "image", "Image"
+
+
+class ProjectCollabNoteField(models.Model):
+ """
+ Stores individual fields within a collaborative note.
+
+ Each ProjectCollabNote can have multiple fields that are reorderable.
+ Fields can be rich text or images.
+ Related to :model:`rolodex.ProjectCollabNote`.
+ """
+
+ note = models.ForeignKey(
+ ProjectCollabNote,
+ on_delete=models.CASCADE,
+ related_name="fields",
+ help_text="The note this field belongs to",
+ )
+ field_type = models.CharField(
+ "Field Type",
+ max_length=10,
+ choices=ProjectCollabNoteFieldType.choices,
+ default=ProjectCollabNoteFieldType.RICH_TEXT,
+ help_text="Type of content in this field",
+ )
+ content = models.TextField(
+ "Content",
+ default="",
+ blank=True,
+ help_text="HTML content for rich text fields",
+ )
+ image_width = models.IntegerField(
+ "Image Width",
+ blank=True,
+ null=True,
+ editable=False,
+ )
+ image_height = models.IntegerField(
+ "Image Height",
+ blank=True,
+ null=True,
+ editable=False,
+ )
+ image = models.ImageField(
+ "Image",
+ upload_to="collab_note_images/%Y/%m/%d/",
+ blank=True,
+ null=True,
+ width_field="image_width",
+ height_field="image_height",
+ help_text="Image file for image fields",
+ )
+ position = models.PositiveIntegerField(
+ "Position",
+ default=0,
+ help_text="Order within note (lower values first)",
+ )
+ created_at = models.DateTimeField(default=timezone.now)
+ updated_at = models.DateTimeField(default=timezone.now)
+
+ class Meta:
+ ordering = ["note", "position"]
+ verbose_name = "Project collaborative note field"
+ verbose_name_plural = "Project collaborative note fields"
+
+ def __str__(self):
+ return f"{self.note.title} - {self.field_type} field #{self.position}"
+
+ def save(self, *args, **kwargs):
+ self.updated_at = timezone.now()
+ super().save(*args, **kwargs)
+
+ def user_can_view(self, user) -> bool:
+ return self.note.user_can_view(user)
+
+ def user_can_edit(self, user) -> bool:
+ return self.note.user_can_edit(user)
+
+ def user_can_delete(self, user) -> bool:
+ return self.note.user_can_delete(user)
+
+
class ProjectScope(models.Model):
"""Stores an individual scope list, related to an individual :model:`rolodex.Project`."""
diff --git a/ghostwriter/rolodex/tests/test_models.py b/ghostwriter/rolodex/tests/test_models.py
index 4d87acbbe..e0409089f 100644
--- a/ghostwriter/rolodex/tests/test_models.py
+++ b/ghostwriter/rolodex/tests/test_models.py
@@ -20,6 +20,9 @@
OplogEntryFactory,
OplogFactory,
ProjectAssignmentFactory,
+ ProjectCollabNoteFactory,
+ ProjectCollabNoteFieldFactory,
+ ProjectCollabNoteFolderFactory,
ProjectContactFactory,
ProjectFactory,
ProjectInviteFactory,
@@ -36,7 +39,7 @@
UserFactory,
WhiteCardFactory,
)
-from ghostwriter.rolodex.models import Client, Project
+from ghostwriter.rolodex.models import Client, Project, ProjectCollabNote, ProjectCollabNoteField
logging.disable(logging.CRITICAL)
@@ -756,3 +759,87 @@ def test_crud_finding(self):
# Delete
contact.delete()
assert not self.ProjectContact.objects.all().exists()
+
+
+class ProjectCollabNoteModelTests(TestCase):
+ """Collection of tests for :model:`rolodex.ProjectCollabNote`."""
+
+ @classmethod
+ def setUpTestData(cls):
+ cls.user = UserFactory(password="test")
+ cls.project = ProjectFactory()
+ cls.assignment = ProjectAssignmentFactory(project=cls.project, operator=cls.user)
+
+ def test_crud(self):
+ note = ProjectCollabNoteFactory(project=self.project, title="Test Note")
+ self.assertEqual(note.title, "Test Note")
+ self.assertEqual(note.node_type, "note")
+
+ note.title = "Updated"
+ note.save()
+ note.refresh_from_db()
+ self.assertEqual(note.title, "Updated")
+
+ note.delete()
+ self.assertFalse(ProjectCollabNote.objects.filter(pk=note.pk).exists())
+
+ def test_folder_creation(self):
+ folder = ProjectCollabNoteFolderFactory(project=self.project, title="Folder")
+ self.assertEqual(folder.node_type, "folder")
+ self.assertEqual(folder.content, "")
+
+ def test_hierarchical_structure(self):
+ folder = ProjectCollabNoteFolderFactory(project=self.project)
+ note = ProjectCollabNoteFactory(project=self.project, parent=folder)
+ self.assertEqual(note.parent, folder)
+ self.assertIn(note, folder.children.all())
+
+ def test_cascade_delete(self):
+ folder = ProjectCollabNoteFolderFactory(project=self.project)
+ child = ProjectCollabNoteFactory(project=self.project, parent=folder)
+ child_id = child.pk
+ folder.delete()
+ self.assertFalse(ProjectCollabNote.objects.filter(pk=child_id).exists())
+
+ def test_user_can_edit_delegates_to_project(self):
+ note = ProjectCollabNoteFactory(project=self.project)
+ self.assertEqual(note.user_can_edit(self.user), self.project.user_can_edit(self.user))
+
+ def test_str(self):
+ note = ProjectCollabNoteFactory(title="My Note")
+ self.assertEqual(str(note), "My Note (note)")
+
+
+class ProjectCollabNoteFieldModelTests(TestCase):
+ """Collection of tests for :model:`rolodex.ProjectCollabNoteField`."""
+
+ @classmethod
+ def setUpTestData(cls):
+ cls.note = ProjectCollabNoteFactory()
+
+ def test_crud(self):
+ field = ProjectCollabNoteFieldFactory(note=self.note, content="
Hello
")
+ self.assertEqual(field.field_type, "rich_text")
+ self.assertEqual(field.content, "Hello
")
+
+ field.content = "Updated
"
+ field.save()
+ field.refresh_from_db()
+ self.assertEqual(field.content, "Updated
")
+
+ field.delete()
+ self.assertFalse(ProjectCollabNoteField.objects.filter(pk=field.pk).exists())
+
+ def test_cascade_delete_with_note(self):
+ note = ProjectCollabNoteFactory()
+ field = ProjectCollabNoteFieldFactory(note=note)
+ field_id = field.pk
+ note.delete()
+ self.assertFalse(ProjectCollabNoteField.objects.filter(pk=field_id).exists())
+
+ def test_ordering(self):
+ f1 = ProjectCollabNoteFieldFactory(note=self.note, position=2)
+ f2 = ProjectCollabNoteFieldFactory(note=self.note, position=0)
+ f3 = ProjectCollabNoteFieldFactory(note=self.note, position=1)
+ fields = list(self.note.fields.all())
+ self.assertEqual(fields, [f2, f3, f1])
diff --git a/ghostwriter/rolodex/tests/test_views.py b/ghostwriter/rolodex/tests/test_views.py
index 8c06bce1a..ff19ce448 100644
--- a/ghostwriter/rolodex/tests/test_views.py
+++ b/ghostwriter/rolodex/tests/test_views.py
@@ -20,6 +20,8 @@
ClientNoteFactory,
HistoryFactory,
ObjectiveStatusFactory,
+ ProjectCollabNoteFactory,
+ ProjectCollabNoteFieldFactory,
ProjectContactFactory,
ProjectFactory,
ProjectInviteFactory,
@@ -1127,3 +1129,115 @@ def test_default_download_has_nosniff_but_no_csp(self):
self.assertIsNone(response.get("Content-Security-Policy"))
+class CollabNoteImageUploadTests(TestCase):
+ """Collection of tests for :view:`rolodex.ajax_upload_note_field_image`."""
+
+ @classmethod
+ def setUpTestData(cls):
+ cls.user = UserFactory(password=PASSWORD)
+ cls.project = ProjectFactory()
+ cls.assignment = ProjectAssignmentFactory(project=cls.project, operator=cls.user)
+ cls.note = ProjectCollabNoteFactory(project=cls.project)
+ cls.uri = reverse(
+ "rolodex:ajax_upload_note_field_image", kwargs={"pk": cls.note.pk}
+ )
+
+ def setUp(self):
+ self.client = Client()
+ self.client_auth = Client()
+ self.assertTrue(
+ self.client_auth.login(username=self.user.username, password=PASSWORD)
+ )
+
+ def test_view_requires_login(self):
+ response = self.client.post(self.uri)
+ self.assertEqual(response.status_code, 302)
+
+ def test_rejects_get_method(self):
+ response = self.client_auth.get(self.uri)
+ self.assertEqual(response.status_code, 405)
+
+ def test_rejects_missing_image(self):
+ response = self.client_auth.post(self.uri)
+ self.assertEqual(response.status_code, 400)
+
+ def test_upload_creates_field(self):
+ from django.core.files.uploadedfile import SimpleUploadedFile
+ import io
+ from PIL import Image
+
+ img = Image.new("RGB", (10, 10), color="red")
+ buf = io.BytesIO()
+ img.save(buf, format="PNG")
+ buf.seek(0)
+
+ image_file = SimpleUploadedFile("test.png", buf.read(), content_type="image/png")
+ response = self.client_auth.post(self.uri, {"image": image_file})
+ self.assertEqual(response.status_code, 200)
+ data = response.json()
+ self.assertEqual(data["result"], "success")
+ self.assertIn("imageUrl", data)
+ self.assertIn("id", data)
+
+ def test_upload_to_existing_field(self):
+ from django.core.files.uploadedfile import SimpleUploadedFile
+ import io
+ from PIL import Image
+
+ field = ProjectCollabNoteFieldFactory(note=self.note, field_type="image")
+ uri = reverse(
+ "rolodex:ajax_upload_to_existing_field",
+ kwargs={"pk": self.note.pk, "field_pk": field.pk},
+ )
+
+ img = Image.new("RGB", (10, 10), color="blue")
+ buf = io.BytesIO()
+ img.save(buf, format="PNG")
+ buf.seek(0)
+
+ image_file = SimpleUploadedFile("test.png", buf.read(), content_type="image/png")
+ response = self.client_auth.post(uri, {"image": image_file})
+ self.assertEqual(response.status_code, 200)
+ data = response.json()
+ self.assertEqual(data["result"], "success")
+ self.assertIn("imageUrl", data)
+
+
+class CollabNotesExportTests(TestCase):
+ """Collection of tests for :view:`rolodex.export_collab_notes_zip`."""
+
+ @classmethod
+ def setUpTestData(cls):
+ cls.user = UserFactory(password=PASSWORD)
+ cls.project = ProjectFactory()
+ cls.assignment = ProjectAssignmentFactory(project=cls.project, operator=cls.user)
+ cls.uri = reverse(
+ "rolodex:ajax_export_collab_notes", kwargs={"pk": cls.project.pk}
+ )
+
+ def setUp(self):
+ self.client = Client()
+ self.client_auth = Client()
+ self.assertTrue(
+ self.client_auth.login(username=self.user.username, password=PASSWORD)
+ )
+
+ def test_view_requires_login(self):
+ response = self.client.get(self.uri)
+ self.assertEqual(response.status_code, 302)
+
+ def test_export_empty_project(self):
+ response = self.client_auth.get(self.uri)
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response["Content-Type"], "application/zip")
+
+ def test_export_with_notes(self):
+ note = ProjectCollabNoteFactory(project=self.project, title="Test Note")
+ ProjectCollabNoteFieldFactory(
+ note=note, field_type="rich_text", content="Hello world
"
+ )
+ response = self.client_auth.get(self.uri)
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response["Content-Type"], "application/zip")
+
+
diff --git a/ghostwriter/rolodex/urls.py b/ghostwriter/rolodex/urls.py
index 39a782931..648f6703e 100644
--- a/ghostwriter/rolodex/urls.py
+++ b/ghostwriter/rolodex/urls.py
@@ -168,6 +168,21 @@
views.BloodhoundApiFetchView.as_view(),
name="ajax_bloodhound_fetch",
),
+ path(
+ "ajax/note//field/image",
+ views.ajax_upload_note_field_image,
+ name="ajax_upload_note_field_image",
+ ),
+ path(
+ "ajax/note//field//image",
+ views.ajax_upload_note_field_image,
+ name="ajax_upload_to_existing_field",
+ ),
+ path(
+ "ajax/project//notes/export",
+ views.export_collab_notes_zip,
+ name="ajax_export_collab_notes",
+ ),
]
# URLs for :model:`Client` Class Based Views
diff --git a/ghostwriter/rolodex/views.py b/ghostwriter/rolodex/views.py
index 49525a76f..ff18c4a5a 100644
--- a/ghostwriter/rolodex/views.py
+++ b/ghostwriter/rolodex/views.py
@@ -2,10 +2,14 @@
# Standard Libraries
import datetime
+import io
import json
import logging
import mimetypes
import os
+import re
+import zipfile
+from pathlib import Path
from urllib.parse import urlparse
# Django Imports
@@ -21,7 +25,8 @@
HttpResponse,
HttpResponseRedirect,
FileResponse,
- JsonResponse
+ JsonResponse,
+ StreamingHttpResponse,
)
from django.shortcuts import get_object_or_404, redirect
from django.template.loader import render_to_string
@@ -82,6 +87,8 @@
ObjectiveStatus,
Project,
ProjectAssignment,
+ ProjectCollabNote,
+ ProjectCollabNoteField,
ProjectContact,
ProjectInvite,
ProjectNote,
@@ -2289,6 +2296,188 @@ def get_context_data(self, **kwargs):
return ctx
+class NoteImageUploadForm(forms.Form):
+ """Django form for validating image uploads to collab note fields."""
+
+ image = forms.ImageField(
+ help_text="Image file (PNG, JPG, GIF, WebP, max 10 MB)",
+ )
+
+ def clean_image(self):
+ image = self.cleaned_data["image"]
+ allowed_types = {"image/png", "image/jpeg", "image/gif", "image/webp"}
+ if image.content_type not in allowed_types:
+ raise forms.ValidationError(
+ f"Invalid file type: {image.content_type}. "
+ "Allowed types: png, jpg, gif, webp"
+ )
+ max_size = 10 * 1024 * 1024
+ if image.size > max_size:
+ raise forms.ValidationError(
+ f"File too large: {image.size / 1024 / 1024:.1f} MB. Maximum: 10 MB"
+ )
+ return image
+
+
+@login_required
+def ajax_upload_note_field_image(request, pk, field_pk=None):
+ """
+ Upload an image for a :model:`rolodex.ProjectCollabNoteField`.
+
+ If ``field_pk`` is provided, updates an existing image field.
+ Otherwise, creates a new image field attached to the specified note.
+ """
+ if request.method != "POST":
+ return JsonResponse(
+ {"result": "error", "message": "Invalid request method"}, status=405
+ )
+
+ try:
+ note = get_object_or_404(ProjectCollabNote, pk=pk)
+ if not note.user_can_edit(request.user):
+ return ForbiddenJsonResponse()
+
+ form = NoteImageUploadForm(request.POST, request.FILES)
+ if not form.is_valid():
+ errors = form.errors.as_text()
+ return JsonResponse(
+ {"result": "error", "message": errors}, status=400
+ )
+
+ image_file = form.cleaned_data["image"]
+
+ if field_pk is not None:
+ field = get_object_or_404(
+ ProjectCollabNoteField, pk=field_pk, note=note
+ )
+ if field.field_type != "image":
+ return JsonResponse(
+ {"result": "error", "message": "Field is not an image field"},
+ status=400,
+ )
+ field.image = image_file
+ field.save()
+ image_url = request.build_absolute_uri(field.image.url)
+
+ logger.info(
+ "User %s uploaded image to existing field %s for note %s",
+ request.user, field.id, note.id,
+ )
+ return JsonResponse({"result": "success", "imageUrl": image_url})
+
+ max_position = (
+ ProjectCollabNoteField.objects.filter(note=note)
+ .order_by("-position")
+ .values_list("position", flat=True)
+ .first()
+ ) or 0
+
+ field = ProjectCollabNoteField.objects.create(
+ note=note,
+ field_type="image",
+ image=image_file,
+ position=max_position + 1,
+ )
+ image_url = request.build_absolute_uri(field.image.url)
+
+ logger.info(
+ "User %s uploaded image field %s for note %s",
+ request.user, field.id, note.id,
+ )
+ return JsonResponse({
+ "result": "success",
+ "id": field.id,
+ "imageUrl": image_url,
+ "position": field.position,
+ })
+
+ except Exception:
+ logger.exception("Failed to upload image for note %s", pk)
+ return JsonResponse(
+ {"result": "error", "message": "Failed to upload image"}, status=500
+ )
+
+
+def _sanitize_filename(name):
+ """Sanitize a string for use as a filename."""
+ sanitized = re.sub(r'[<>:"/\\|?*]', "_", name)
+ sanitized = re.sub(r"_+", "_", sanitized)
+ sanitized = sanitized.strip(" _")
+ if len(sanitized) > 200:
+ sanitized = sanitized[:200]
+ return sanitized or "untitled"
+
+
+@login_required
+def export_collab_notes_zip(request, pk):
+ """Export all collab notes for a project as a streaming ZIP response."""
+ from markdownify import markdownify as md
+
+ project = get_object_or_404(Project, pk=pk)
+
+ if not project.user_can_edit(request.user):
+ return ForbiddenJsonResponse()
+
+ notes = ProjectCollabNote.objects.filter(project=project).prefetch_related("fields")
+
+ notes_by_parent = {}
+ for note in notes:
+ notes_by_parent.setdefault(note.parent_id, []).append(note)
+ for parent_id in notes_by_parent:
+ notes_by_parent[parent_id].sort(key=lambda n: n.position)
+
+ buffer = io.BytesIO()
+ with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf:
+ def process_node(note, path_prefix=""):
+ safe_title = _sanitize_filename(note.title)
+ if note.node_type == "folder":
+ folder_path = f"{path_prefix}{safe_title}/"
+ for child in notes_by_parent.get(note.id, []):
+ process_node(child, folder_path)
+ else:
+ md_content = f"# {note.title}\n\n"
+ image_num = 1
+ fields = sorted(note.fields.all(), key=lambda f: f.position)
+ for field in fields:
+ if field.field_type == "rich_text":
+ converted = md(field.content or "")
+ md_content += converted + "\n\n"
+ elif field.field_type == "image" and field.image:
+ try:
+ img_filename = (
+ f"image_{image_num}{Path(field.image.name).suffix}"
+ )
+ img_folder = f"{path_prefix}{safe_title}_images/"
+ zf.write(
+ field.image.path, f"{img_folder}{img_filename}"
+ )
+ md_content += (
+ f"![Image {image_num}]"
+ f"(./{safe_title}_images/{img_filename})\n\n"
+ )
+ image_num += 1
+ except (FileNotFoundError, OSError):
+ logger.exception(
+ "Could not include image for field %s", field.id
+ )
+ md_content += f"\n\n"
+ image_num += 1
+
+ zf.writestr(f"{path_prefix}{safe_title}.md", md_content)
+
+ for note in notes_by_parent.get(None, []):
+ process_node(note)
+
+ buffer.seek(0)
+
+ safe_codename = _sanitize_filename(project.codename)
+ response = StreamingHttpResponse(
+ buffer, content_type="application/zip"
+ )
+ add_content_disposition_header(response, f"{safe_codename}_notes.zip")
+ return response
+
+
class BloodhoundApiBaseView(RoleBasedAccessControlMixin, View):
project: Project | None
bh_api: Project | BloodHoundConfiguration
diff --git a/hasura-docker/metadata/databases/default/tables/public_rolodex_project.yaml b/hasura-docker/metadata/databases/default/tables/public_rolodex_project.yaml
index 4832d1e4d..dced821e8 100644
--- a/hasura-docker/metadata/databases/default/tables/public_rolodex_project.yaml
+++ b/hasura-docker/metadata/databases/default/tables/public_rolodex_project.yaml
@@ -65,6 +65,13 @@ array_relationships:
table:
name: shepherd_transientserver
schema: public
+ - name: collabNotes
+ using:
+ foreign_key_constraint_on:
+ column: project_id
+ table:
+ name: rolodex_projectcollabnote
+ schema: public
- name: comments
using:
foreign_key_constraint_on:
diff --git a/hasura-docker/metadata/databases/default/tables/public_rolodex_projectcollabnote.yaml b/hasura-docker/metadata/databases/default/tables/public_rolodex_projectcollabnote.yaml
new file mode 100644
index 000000000..0f9bc15a8
--- /dev/null
+++ b/hasura-docker/metadata/databases/default/tables/public_rolodex_projectcollabnote.yaml
@@ -0,0 +1,163 @@
+table:
+ name: rolodex_projectcollabnote
+ schema: public
+configuration:
+ column_config:
+ node_type:
+ custom_name: nodeType
+ parent_id:
+ custom_name: parentId
+ project_id:
+ custom_name: projectId
+ created_at:
+ custom_name: createdAt
+ updated_at:
+ custom_name: updatedAt
+ custom_column_names:
+ node_type: nodeType
+ parent_id: parentId
+ project_id: projectId
+ created_at: createdAt
+ updated_at: updatedAt
+ custom_name: projectCollabNote
+ custom_root_fields: {}
+object_relationships:
+ - name: project
+ using:
+ foreign_key_constraint_on: project_id
+ - name: parent
+ using:
+ foreign_key_constraint_on: parent_id
+array_relationships:
+ - name: children
+ using:
+ foreign_key_constraint_on:
+ column: parent_id
+ table:
+ name: rolodex_projectcollabnote
+ schema: public
+ - name: fields
+ using:
+ foreign_key_constraint_on:
+ column: note_id
+ table:
+ name: rolodex_projectcollabnotefield
+ schema: public
+insert_permissions:
+ - role: manager
+ permission:
+ check: {}
+ columns:
+ - title
+ - node_type
+ - content
+ - position
+ - parent_id
+ - project_id
+ - role: user
+ permission:
+ check:
+ project:
+ _or:
+ - assignments:
+ operator_id:
+ _eq: X-Hasura-User-Id
+ - invites:
+ user_id:
+ _eq: X-Hasura-User-Id
+ - client:
+ invites:
+ user_id:
+ _eq: X-Hasura-User-Id
+ columns:
+ - title
+ - node_type
+ - content
+ - position
+ - parent_id
+ - project_id
+select_permissions:
+ - role: manager
+ permission:
+ columns: '*'
+ filter: {}
+ - role: user
+ permission:
+ columns: '*'
+ filter:
+ project:
+ _or:
+ - assignments:
+ operator_id:
+ _eq: X-Hasura-User-Id
+ - invites:
+ user_id:
+ _eq: X-Hasura-User-Id
+ - client:
+ invites:
+ user_id:
+ _eq: X-Hasura-User-Id
+update_permissions:
+ - role: manager
+ permission:
+ columns:
+ - title
+ - node_type
+ - content
+ - position
+ - parent_id
+ filter: {}
+ check: {}
+ - role: user
+ permission:
+ columns:
+ - title
+ - node_type
+ - content
+ - position
+ - parent_id
+ filter:
+ project:
+ _or:
+ - assignments:
+ operator_id:
+ _eq: X-Hasura-User-Id
+ - invites:
+ user_id:
+ _eq: X-Hasura-User-Id
+ - client:
+ invites:
+ user_id:
+ _eq: X-Hasura-User-Id
+ check:
+ project:
+ _or:
+ - assignments:
+ operator_id:
+ _eq: X-Hasura-User-Id
+ - invites:
+ user_id:
+ _eq: X-Hasura-User-Id
+ - client:
+ invites:
+ user_id:
+ _eq: X-Hasura-User-Id
+delete_permissions:
+ - role: manager
+ permission:
+ filter: {}
+ - role: user
+ permission:
+ filter:
+ project:
+ _or:
+ - assignments:
+ operator_id:
+ _eq: X-Hasura-User-Id
+ - invites:
+ user_id:
+ _eq: X-Hasura-User-Id
+ - client:
+ invites:
+ user_id:
+ _eq: X-Hasura-User-Id
diff --git a/hasura-docker/metadata/databases/default/tables/public_rolodex_projectcollabnotefield.yaml b/hasura-docker/metadata/databases/default/tables/public_rolodex_projectcollabnotefield.yaml
new file mode 100644
index 000000000..93a1df8c6
--- /dev/null
+++ b/hasura-docker/metadata/databases/default/tables/public_rolodex_projectcollabnotefield.yaml
@@ -0,0 +1,145 @@
+table:
+ name: rolodex_projectcollabnotefield
+ schema: public
+configuration:
+ column_config:
+ field_type:
+ custom_name: fieldType
+ note_id:
+ custom_name: noteId
+ image_width:
+ custom_name: imageWidth
+ image_height:
+ custom_name: imageHeight
+ created_at:
+ custom_name: createdAt
+ updated_at:
+ custom_name: updatedAt
+ custom_column_names:
+ field_type: fieldType
+ note_id: noteId
+ image_width: imageWidth
+ image_height: imageHeight
+ created_at: createdAt
+ updated_at: updatedAt
+ custom_name: projectCollabNoteField
+ custom_root_fields: {}
+object_relationships:
+ - name: note
+ using:
+ foreign_key_constraint_on: note_id
+insert_permissions:
+ - role: manager
+ permission:
+ check: {}
+ columns:
+ - field_type
+ - content
+ - position
+ - note_id
+ - role: user
+ permission:
+ check:
+ note:
+ project:
+ _or:
+ - assignments:
+ operator_id:
+ _eq: X-Hasura-User-Id
+ - invites:
+ user_id:
+ _eq: X-Hasura-User-Id
+ - client:
+ invites:
+ user_id:
+ _eq: X-Hasura-User-Id
+ columns:
+ - field_type
+ - content
+ - position
+ - note_id
+select_permissions:
+ - role: manager
+ permission:
+ columns: '*'
+ filter: {}
+ - role: user
+ permission:
+ columns: '*'
+ filter:
+ note:
+ project:
+ _or:
+ - assignments:
+ operator_id:
+ _eq: X-Hasura-User-Id
+ - invites:
+ user_id:
+ _eq: X-Hasura-User-Id
+ - client:
+ invites:
+ user_id:
+ _eq: X-Hasura-User-Id
+update_permissions:
+ - role: manager
+ permission:
+ columns:
+ - field_type
+ - content
+ - position
+ filter: {}
+ check: {}
+ - role: user
+ permission:
+ columns:
+ - field_type
+ - content
+ - position
+ filter:
+ note:
+ project:
+ _or:
+ - assignments:
+ operator_id:
+ _eq: X-Hasura-User-Id
+ - invites:
+ user_id:
+ _eq: X-Hasura-User-Id
+ - client:
+ invites:
+ user_id:
+ _eq: X-Hasura-User-Id
+ check:
+ note:
+ project:
+ _or:
+ - assignments:
+ operator_id:
+ _eq: X-Hasura-User-Id
+ - invites:
+ user_id:
+ _eq: X-Hasura-User-Id
+ - client:
+ invites:
+ user_id:
+ _eq: X-Hasura-User-Id
+delete_permissions:
+ - role: manager
+ permission:
+ filter: {}
+ - role: user
+ permission:
+ filter:
+ note:
+ project:
+ _or:
+ - assignments:
+ operator_id:
+ _eq: X-Hasura-User-Id
+ - invites:
+ user_id:
+ _eq: X-Hasura-User-Id
+ - client:
+ invites:
+ user_id:
+ _eq: X-Hasura-User-Id
diff --git a/hasura-docker/metadata/databases/default/tables/tables.yaml b/hasura-docker/metadata/databases/default/tables/tables.yaml
index 0fe147351..05653d394 100644
--- a/hasura-docker/metadata/databases/default/tables/tables.yaml
+++ b/hasura-docker/metadata/databases/default/tables/tables.yaml
@@ -32,6 +32,8 @@
- "!include public_rolodex_objectivepriority.yaml"
- "!include public_rolodex_objectivestatus.yaml"
- "!include public_rolodex_project.yaml"
+- "!include public_rolodex_projectcollabnote.yaml"
+- "!include public_rolodex_projectcollabnotefield.yaml"
- "!include public_rolodex_projectassignment.yaml"
- "!include public_rolodex_projectcontact.yaml"
- "!include public_rolodex_projectinvite.yaml"
diff --git a/javascript/src/collab_server/handlers/project_collab_note.ts b/javascript/src/collab_server/handlers/project_collab_note.ts
new file mode 100644
index 000000000..9e2ccf199
--- /dev/null
+++ b/javascript/src/collab_server/handlers/project_collab_note.ts
@@ -0,0 +1,135 @@
+import { type ModelHandler } from "../base_handler";
+import * as Y from "yjs";
+import { htmlToYjs, yjsToHtml } from "../yjs_converters";
+import { ApolloClient, gql as rawGql } from "@apollo/client/core";
+
+interface FieldData {
+ id: string;
+ fieldType: string;
+ image: string | null;
+ position: number;
+}
+
+const GET_QUERY = rawGql`
+ query GET_PROJECT_COLLAB_NOTE($id: bigint!) {
+ projectCollabNote_by_pk(id: $id) {
+ content
+ title
+ nodeType
+ fields(order_by: {position: asc}) {
+ id
+ fieldType
+ content
+ image
+ position
+ }
+ }
+ }
+`;
+
+const SET_MUTATION = rawGql`
+ mutation SET_PROJECT_COLLAB_NOTE_FIELDS($updates: [projectCollabNoteField_updates!]!) {
+ update_projectCollabNoteField_many(updates: $updates) {
+ affected_rows
+ }
+ }
+`;
+
+const ProjectCollabNoteItemHandler: ModelHandler = {
+ async load(client: ApolloClient, id: number) {
+ const res = await client.query({
+ query: GET_QUERY,
+ variables: { id },
+ });
+ if (res.error || res.errors) throw res.error || res.errors;
+
+ const obj = res.data.projectCollabNote_by_pk;
+ if (!obj) throw new Error("No object");
+ if (obj.nodeType !== "note") throw new Error("Cannot edit folder content");
+
+ const doc = new Y.Doc();
+ let fieldDataArr: FieldData[];
+
+ doc.transact(() => {
+ const meta = doc.get("meta", Y.Map);
+ meta.set("title", obj.title);
+
+ const fieldsArray = new Y.Array();
+
+ if (obj.fields.length === 0 && obj.content) {
+ const legacyField: FieldData = {
+ id: "legacy",
+ fieldType: "rich_text",
+ image: null,
+ position: 0,
+ };
+ fieldsArray.push([legacyField]);
+ htmlToYjs(obj.content, doc.get("field_legacy", Y.XmlFragment));
+ fieldDataArr = [legacyField];
+ } else {
+ fieldDataArr = obj.fields.map((field: any) => {
+ const imageUrl = field.image ? `/media/${field.image}` : null;
+ const fieldData: FieldData = {
+ id: field.id.toString(),
+ fieldType: field.fieldType,
+ image: imageUrl,
+ position: field.position,
+ };
+ fieldsArray.push([fieldData]);
+
+ if (field.fieldType === "rich_text") {
+ htmlToYjs(
+ field.content,
+ doc.get(`field_${field.id}`, Y.XmlFragment)
+ );
+ }
+ return fieldData;
+ });
+ }
+
+ meta.set("fields", fieldsArray);
+ });
+
+ return [doc, fieldDataArr!];
+ },
+
+ async save(client: ApolloClient, id: number, doc: Y.Doc, _data: FieldData[]) {
+ let queryVars: any;
+ doc.transact(() => {
+ const meta = doc.get("meta", Y.Map) as Y.Map;
+ const fieldsArray = meta.get("fields") as Y.Array | undefined;
+
+ if (!fieldsArray) {
+ queryVars = { updates: [] };
+ return;
+ }
+
+ const currentFields: FieldData[] = [];
+ for (let i = 0; i < fieldsArray.length; i++) {
+ currentFields.push(fieldsArray.get(i));
+ }
+
+ const updates = currentFields
+ .filter((f) => f.fieldType === "rich_text" && f.id !== "legacy")
+ .map((f) => ({
+ where: { id: { _eq: parseInt(f.id) } },
+ _set: {
+ content: yjsToHtml(
+ doc.get(`field_${f.id}`, Y.XmlFragment)
+ ),
+ },
+ }));
+ queryVars = { updates };
+ });
+
+ if (queryVars.updates.length > 0) {
+ const res = await client.mutate({
+ mutation: SET_MUTATION,
+ variables: queryVars,
+ });
+ if (res.errors) throw res.errors;
+ }
+ },
+};
+
+export default ProjectCollabNoteItemHandler;
diff --git a/javascript/src/collab_server/handlers/project_tree_sync.ts b/javascript/src/collab_server/handlers/project_tree_sync.ts
new file mode 100644
index 000000000..ecccf6c82
--- /dev/null
+++ b/javascript/src/collab_server/handlers/project_tree_sync.ts
@@ -0,0 +1,54 @@
+import { type ModelHandler } from "../base_handler";
+import * as Y from "yjs";
+import { ApolloClient, gql as rawGql } from "@apollo/client/core";
+
+const GET_TREE_QUERY = rawGql`
+ query GET_PROJECT_TREE($id: bigint!) {
+ projectCollabNote(
+ where: { projectId: { _eq: $id } }
+ order_by: [{ position: asc }, { title: asc }]
+ ) {
+ id
+ title
+ nodeType
+ parentId
+ position
+ }
+ }
+`;
+
+interface TreeNode {
+ id: number;
+ title: string;
+ nodeType: string;
+ parentId: number | null;
+ position: number;
+}
+
+const ProjectTreeSyncHandler: ModelHandler = {
+ async load(client: ApolloClient, id: number) {
+ const res = await client.query({
+ query: GET_TREE_QUERY,
+ variables: { id },
+ });
+ if (res.error || res.errors) throw res.error || res.errors;
+
+ const doc = new Y.Doc();
+ doc.transact(() => {
+ const treeArray = doc.get("tree", Y.Array) as Y.Array;
+ const nodes: TreeNode[] = res.data.projectCollabNote || [];
+ if (nodes.length > 0) {
+ treeArray.push(nodes);
+ }
+ });
+
+ return [doc, null];
+ },
+
+ async save() {
+ // Tree data is persisted via GraphQL mutations from the frontend.
+ // The Yjs doc here acts as a real-time sync cache only.
+ },
+};
+
+export default ProjectTreeSyncHandler;
diff --git a/javascript/src/collab_server/index.ts b/javascript/src/collab_server/index.ts
index 54b0504cf..03e63fac6 100644
--- a/javascript/src/collab_server/index.ts
+++ b/javascript/src/collab_server/index.ts
@@ -21,6 +21,8 @@ import FindingHandler from "./handlers/finding";
import ReportFindingLinkHandler from "./handlers/report_finding_link";
import ReportHandler from "./handlers/report";
import ProjectHandler from "./handlers/project";
+import ProjectCollabNoteItemHandler from "./handlers/project_collab_note";
+import ProjectTreeSyncHandler from "./handlers/project_tree_sync";
// Extend this with your model handlers. See how-to-collab.md.
const HANDLERS_ARR: [string, ModelHandler][] = [
@@ -30,6 +32,8 @@ const HANDLERS_ARR: [string, ModelHandler][] = [
["report_finding_link", ReportFindingLinkHandler],
["report", ReportHandler],
["project", ProjectHandler],
+ ["project_collab_note", ProjectCollabNoteItemHandler],
+ ["project_tree_sync", ProjectTreeSyncHandler],
];
const HANDLERS: Map> = new Map(HANDLERS_ARR);
@@ -260,6 +264,13 @@ const server = new Server({
async afterUnloadDocument(data) {
documentData.delete(data.documentName);
},
+
+ async onStateless(data) {
+ const { documentName, document, payload } = data;
+ if (documentName.startsWith("project_tree_sync/")) {
+ document.broadcastStateless(payload);
+ }
+ },
});
server.listen();
diff --git a/javascript/src/frontend/collab_forms/forms/project_collabnote.tsx b/javascript/src/frontend/collab_forms/forms/project_collabnote.tsx
deleted file mode 100644
index 268ef4bc3..000000000
--- a/javascript/src/frontend/collab_forms/forms/project_collabnote.tsx
+++ /dev/null
@@ -1,52 +0,0 @@
-import ReactModal from "react-modal";
-import { ConnectionStatus, usePageConnection } from "../connection";
-import { createRoot, Root } from "react-dom/client";
-import RichTextEditor from "../rich_text_editor";
-import ErrorBoundary from "../error_boundary";
-
-function ProjectCollabNoteForm() {
- const { provider, status, connected } = usePageConnection({
- model: "project",
- });
-
- return (
- <>
-
-
- >
- );
-}
-
-document.addEventListener("DOMContentLoaded", () => {
- ReactModal.setAppElement(
- document.querySelector("div.wrapper") as HTMLElement
- );
-
- const $ = (window as any).$;
- let root: Root | null = null;
-
- $("#id_collab_notes").on("shown.bs.tab", () => {
- if (root !== null) return;
- root = createRoot(document.getElementById("collab_notes_container")!);
- root.render(
-
-
-
- );
- });
-
- $("#id_collab_notes").on("hidden.bs.tab", () => {
- if (root !== null) root.unmount();
- root = null;
- });
-});
diff --git a/javascript/src/frontend/collab_forms/forms/project_collabnotes/AddFieldToolbar.tsx b/javascript/src/frontend/collab_forms/forms/project_collabnotes/AddFieldToolbar.tsx
new file mode 100644
index 000000000..0581d27ad
--- /dev/null
+++ b/javascript/src/frontend/collab_forms/forms/project_collabnotes/AddFieldToolbar.tsx
@@ -0,0 +1,28 @@
+interface AddFieldToolbarProps {
+ onAddRichText: () => void;
+ onAddImage: () => void;
+}
+
+export default function AddFieldToolbar({
+ onAddRichText,
+ onAddImage,
+}: AddFieldToolbarProps) {
+ return (
+
+
+ Add Text
+
+
+ Add Image
+
+
+ );
+}
diff --git a/javascript/src/frontend/collab_forms/forms/project_collabnotes/CreateModal.tsx b/javascript/src/frontend/collab_forms/forms/project_collabnotes/CreateModal.tsx
new file mode 100644
index 000000000..8d8f31be3
--- /dev/null
+++ b/javascript/src/frontend/collab_forms/forms/project_collabnotes/CreateModal.tsx
@@ -0,0 +1,93 @@
+import { useState } from "react";
+import ReactModal from "react-modal";
+
+interface CreateModalProps {
+ isOpen: boolean;
+ type: "note" | "folder";
+ onClose: () => void;
+ onCreate: (title: string) => void;
+}
+
+export default function CreateModal({
+ isOpen,
+ type,
+ onClose,
+ onCreate,
+}: CreateModalProps) {
+ const [title, setTitle] = useState("");
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!title.trim()) return;
+
+ setIsSubmitting(true);
+ try {
+ await onCreate(title.trim());
+ setTitle("");
+ onClose();
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ const handleClose = () => {
+ setTitle("");
+ onClose();
+ };
+
+ return (
+
+
+
+
+ Create New {type === "folder" ? "Folder" : "Note"}
+
+
+
+
+
+
+ );
+}
diff --git a/javascript/src/frontend/collab_forms/forms/project_collabnotes/DeleteConfirmModal.tsx b/javascript/src/frontend/collab_forms/forms/project_collabnotes/DeleteConfirmModal.tsx
new file mode 100644
index 000000000..276848f13
--- /dev/null
+++ b/javascript/src/frontend/collab_forms/forms/project_collabnotes/DeleteConfirmModal.tsx
@@ -0,0 +1,80 @@
+import ReactModal from "react-modal";
+
+type DeleteItemType = "rich_text" | "image" | "note" | "folder" | null;
+
+const TYPE_LABELS: Record = {
+ rich_text: "text field",
+ image: "image",
+ note: "note",
+ folder: "folder",
+};
+
+interface DeleteConfirmModalProps {
+ isOpen: boolean;
+ itemType: DeleteItemType;
+ itemTitle?: string;
+ onClose: () => void;
+ onConfirm: () => void;
+}
+
+export default function DeleteConfirmModal({
+ isOpen,
+ itemType,
+ itemTitle,
+ onClose,
+ onConfirm,
+}: DeleteConfirmModalProps) {
+ const typeLabel = (itemType && TYPE_LABELS[itemType]) || "item";
+ const isFolder = itemType === "folder";
+
+ return (
+
+
+
+
+
+ Confirm Delete
+
+
+
+
+
+ Are you sure you want to delete {itemTitle ? (
+ <>the {typeLabel} "{itemTitle}" >
+ ) : (
+ <>this {typeLabel}>
+ )}?
+
+ {isFolder && (
+
+
+ This will also delete all notes and subfolders inside.
+
+ )}
+
+ This action cannot be undone.
+
+
+
+
+ Cancel
+
+
+ Delete
+
+
+
+
+ );
+}
diff --git a/javascript/src/frontend/collab_forms/forms/project_collabnotes/ImageField.tsx b/javascript/src/frontend/collab_forms/forms/project_collabnotes/ImageField.tsx
new file mode 100644
index 000000000..d5b12fbca
--- /dev/null
+++ b/javascript/src/frontend/collab_forms/forms/project_collabnotes/ImageField.tsx
@@ -0,0 +1,61 @@
+import { useState } from "react";
+import ImageFieldPlaceholder from "./ImageFieldPlaceholder";
+
+interface ImageFieldProps {
+ imageUrl: string | null | undefined;
+ onDelete: () => void;
+ onUpload?: (file: File) => void;
+ uploading?: boolean;
+}
+
+export default function ImageField({
+ imageUrl,
+ onDelete,
+ onUpload,
+ uploading = false,
+}: ImageFieldProps) {
+ const [showDeleteOverlay, setShowDeleteOverlay] = useState(false);
+
+ if (!imageUrl) {
+ return (
+ {})}
+ uploading={uploading}
+ />
+ );
+ }
+
+ return (
+ setShowDeleteOverlay(true)}
+ onMouseLeave={() => setShowDeleteOverlay(false)}
+ style={{ position: "relative", marginBottom: "1rem" }}
+ >
+
+ {showDeleteOverlay && (
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/javascript/src/frontend/collab_forms/forms/project_collabnotes/ImageFieldPlaceholder.tsx b/javascript/src/frontend/collab_forms/forms/project_collabnotes/ImageFieldPlaceholder.tsx
new file mode 100644
index 000000000..f1e40423e
--- /dev/null
+++ b/javascript/src/frontend/collab_forms/forms/project_collabnotes/ImageFieldPlaceholder.tsx
@@ -0,0 +1,118 @@
+import { useRef, useState, useCallback, useEffect } from "react";
+
+interface ImageFieldPlaceholderProps {
+ onUpload: (file: File) => void;
+ uploading: boolean;
+}
+
+export default function ImageFieldPlaceholder({
+ onUpload,
+ uploading,
+}: ImageFieldPlaceholderProps) {
+ const fileInputRef = useRef(null);
+ const [dragActive, setDragActive] = useState(false);
+ const containerRef = useRef(null);
+
+ const handleFileSelect = useCallback(
+ (file: File) => {
+ if (file && file.type.startsWith("image/")) {
+ onUpload(file);
+ }
+ },
+ [onUpload]
+ );
+
+ const handleFileInputChange = (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (file) handleFileSelect(file);
+ e.target.value = "";
+ };
+
+ const handleDrag = (e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ if (e.type === "dragenter" || e.type === "dragover") {
+ setDragActive(true);
+ } else if (e.type === "dragleave") {
+ setDragActive(false);
+ }
+ };
+
+ const handleDrop = (e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setDragActive(false);
+ const file = e.dataTransfer.files?.[0];
+ if (file) handleFileSelect(file);
+ };
+
+ const handlePaste = useCallback(
+ (event: ClipboardEvent) => {
+ const items = event.clipboardData?.items;
+ if (!items) return;
+ for (let i = 0; i < items.length; i++) {
+ if (items[i].type.startsWith("image/")) {
+ event.preventDefault();
+ const file = items[i].getAsFile();
+ if (file) handleFileSelect(file);
+ break;
+ }
+ }
+ },
+ [handleFileSelect]
+ );
+
+ useEffect(() => {
+ const container = containerRef.current;
+ if (!container) return;
+ container.addEventListener("paste", handlePaste);
+ return () => container.removeEventListener("paste", handlePaste);
+ }, [handlePaste]);
+
+ const handleClick = () => {
+ if (!uploading) fileInputRef.current?.click();
+ };
+
+ const className = [
+ "image-field-placeholder",
+ dragActive && "drag-active",
+ uploading && "uploading",
+ ].filter(Boolean).join(" ");
+
+ return (
+
+ {uploading ? (
+ <>
+
+ Uploading...
+ >
+ ) : (
+ <>
+
+
+ {dragActive ? "Drop image here" : "Paste, drag-drop, or click to upload"}
+
+
+ PNG, JPG, GIF, WebP (max 10 MB)
+
+ >
+ )}
+
+
+ );
+}
diff --git a/javascript/src/frontend/collab_forms/forms/project_collabnotes/NoteEditor.tsx b/javascript/src/frontend/collab_forms/forms/project_collabnotes/NoteEditor.tsx
new file mode 100644
index 000000000..1770897e5
--- /dev/null
+++ b/javascript/src/frontend/collab_forms/forms/project_collabnotes/NoteEditor.tsx
@@ -0,0 +1,301 @@
+import { useEffect, useState, useCallback } from "react";
+import {
+ DndContext,
+ closestCenter,
+ KeyboardSensor,
+ PointerSensor,
+ useSensor,
+ useSensors,
+ DragEndEvent,
+} from "@dnd-kit/core";
+import {
+ arrayMove,
+ SortableContext,
+ sortableKeyboardCoordinates,
+ verticalListSortingStrategy,
+} from "@dnd-kit/sortable";
+import * as Y from "yjs";
+import {
+ ConnectionStatus,
+ usePageConnection,
+} from "../../connection";
+import NoteFieldEditor from "./NoteFieldEditor";
+import AddFieldToolbar from "./AddFieldToolbar";
+import DeleteConfirmModal from "./DeleteConfirmModal";
+import { useFieldMutations } from "./hooks/useFieldMutations";
+import { useImageUpload } from "./hooks/useImageUpload";
+import type { NoteField } from "./types";
+
+interface NoteEditorProps {
+ noteId: number;
+}
+
+/**
+ * NoteEditor derives fields directly from the Yjs document via observeDeep.
+ * The Yjs meta.fields Y.Array is the single source of truth for field state.
+ * React state is updated only via Yjs observation callbacks.
+ */
+export default function NoteEditor({ noteId }: NoteEditorProps) {
+ const { provider, status, connected } = usePageConnection({
+ model: "project_collab_note",
+ id: noteId.toString(),
+ });
+
+ const [fields, setFields] = useState([]);
+ const [pendingDeleteField, setPendingDeleteField] = useState(null);
+ const { createRichTextField, createImageField, deleteField, reorderFields } = useFieldMutations();
+ const { uploading, uploadingFieldId, error, uploadImage, uploadToField, handlePaste } = useImageUpload();
+
+ const sensors = useSensors(
+ useSensor(PointerSensor, {
+ activationConstraint: { distance: 8 },
+ }),
+ useSensor(KeyboardSensor, {
+ coordinateGetter: sortableKeyboardCoordinates,
+ })
+ );
+
+ // Derive fields from Yjs document via observeDeep (Comment #17)
+ // The Yjs doc is the single source of truth; React state is read-only derived.
+ useEffect(() => {
+ if (!connected) return;
+
+ const meta = provider.document.getMap("meta");
+
+ const syncFieldsFromYjs = () => {
+ const fieldsArray = meta.get("fields") as Y.Array | undefined;
+ if (!fieldsArray) {
+ setFields([]);
+ return;
+ }
+ const fieldsList: NoteField[] = [];
+ for (let i = 0; i < fieldsArray.length; i++) {
+ fieldsList.push(fieldsArray.get(i));
+ }
+ setFields(fieldsList);
+ };
+
+ // Initial sync
+ syncFieldsFromYjs();
+
+ // Observe all deep changes to meta (handles fields array creation,
+ // item additions/removals, and nested property changes)
+ meta.observeDeep(syncFieldsFromYjs);
+
+ return () => {
+ meta.unobserveDeep(syncFieldsFromYjs);
+ };
+ }, [provider, connected]);
+
+ // Helper: mutate the Yjs fields array (all field changes go through here)
+ const getFieldsArray = useCallback((): Y.Array | null => {
+ if (!connected) return null;
+ const meta = provider.document.getMap("meta");
+ return (meta.get("fields") as Y.Array) || null;
+ }, [provider, connected]);
+
+ const ensureFieldsArray = useCallback((): Y.Array => {
+ const meta = provider.document.getMap("meta");
+ let arr = meta.get("fields") as Y.Array | undefined;
+ if (!arr) {
+ arr = new Y.Array();
+ meta.set("fields", arr);
+ }
+ return arr;
+ }, [provider]);
+
+ // Clipboard paste for images
+ useEffect(() => {
+ const handlePasteEvent = async (event: ClipboardEvent) => {
+ const result = await handlePaste(noteId, event);
+ if (result) {
+ const newField: NoteField = {
+ id: result.id,
+ fieldType: "image",
+ image: result.imageUrl,
+ position: result.position,
+ };
+ const arr = ensureFieldsArray();
+ arr.push([newField]);
+ }
+ };
+
+ document.addEventListener("paste", handlePasteEvent);
+ return () => document.removeEventListener("paste", handlePasteEvent);
+ }, [noteId, handlePaste, ensureFieldsArray]);
+
+ const handleAddRichText = useCallback(async () => {
+ try {
+ const result = await createRichTextField(noteId);
+ const newField: NoteField = {
+ id: result.id,
+ fieldType: "rich_text",
+ image: null,
+ position: result.position,
+ };
+ const arr = ensureFieldsArray();
+ arr.push([newField]);
+ } catch (err) {
+ console.error("Failed to create rich text field:", err);
+ }
+ }, [noteId, createRichTextField, ensureFieldsArray]);
+
+ const handleAddImage = useCallback(async () => {
+ try {
+ const result = await createImageField(noteId);
+ const newField: NoteField = {
+ id: result.id,
+ fieldType: "image",
+ image: null,
+ position: result.position,
+ };
+ const arr = ensureFieldsArray();
+ arr.push([newField]);
+ } catch (err) {
+ console.error("Failed to create image field:", err);
+ }
+ }, [noteId, createImageField, ensureFieldsArray]);
+
+ const handleUploadToField = useCallback(
+ async (fieldId: string, file: File) => {
+ try {
+ const result = await uploadToField(noteId, fieldId, file);
+ if (result) {
+ const arr = getFieldsArray();
+ if (arr) {
+ provider.document.transact(() => {
+ for (let i = 0; i < arr.length; i++) {
+ const f = arr.get(i);
+ if (f.id === fieldId) {
+ arr.delete(i, 1);
+ arr.insert(i, [{ ...f, image: result.imageUrl }]);
+ break;
+ }
+ }
+ });
+ }
+ }
+ } catch (err) {
+ console.error("Failed to upload image to field:", err);
+ }
+ },
+ [noteId, uploadToField, getFieldsArray, provider]
+ );
+
+ const requestDeleteField = useCallback((field: NoteField) => {
+ setPendingDeleteField(field);
+ }, []);
+
+ const handleConfirmDelete = useCallback(async () => {
+ if (!pendingDeleteField) return;
+ const fieldId = pendingDeleteField.id;
+ setPendingDeleteField(null);
+ try {
+ await deleteField(fieldId);
+ const arr = getFieldsArray();
+ if (arr) {
+ for (let i = 0; i < arr.length; i++) {
+ if (arr.get(i).id === fieldId) {
+ arr.delete(i, 1);
+ break;
+ }
+ }
+ }
+ } catch (err) {
+ console.error("Failed to delete field:", err);
+ }
+ }, [pendingDeleteField, deleteField, getFieldsArray]);
+
+ const handleCancelDelete = useCallback(() => {
+ setPendingDeleteField(null);
+ }, []);
+
+ const handleDragEnd = useCallback(
+ async (event: DragEndEvent) => {
+ const { active, over } = event;
+ if (!over || active.id === over.id) return;
+
+ const oldIndex = fields.findIndex((f) => f.id === active.id);
+ const newIndex = fields.findIndex((f) => f.id === over.id);
+ if (oldIndex === -1 || newIndex === -1) return;
+
+ const newFields = arrayMove(fields, oldIndex, newIndex);
+
+ const updates = newFields.map((field, index) => ({
+ id: field.id,
+ position: index * 1000,
+ noteId,
+ }));
+
+ // Update Yjs (which triggers observeDeep → setFields)
+ const arr = getFieldsArray();
+ if (arr) {
+ provider.document.transact(() => {
+ arr.delete(0, arr.length);
+ const reordered = newFields.map((f, i) => ({ ...f, position: i * 1000 }));
+ arr.push(reordered);
+ });
+ }
+
+ try {
+ await reorderFields(updates);
+ } catch (err) {
+ console.error("Failed to reorder fields:", err);
+ }
+ },
+ [fields, noteId, reorderFields, getFieldsArray, provider]
+ );
+
+ return (
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+
+ f.id)}
+ strategy={verticalListSortingStrategy}
+ >
+ {fields.map((field) => (
+ requestDeleteField(field)}
+ onUploadImage={handleUploadToField}
+ uploadingFieldId={uploadingFieldId}
+ />
+ ))}
+
+
+
+ {fields.length === 0 && connected && (
+
+ No fields yet. Add a text field or image above to get started.
+
+ )}
+
+
+
+ );
+}
diff --git a/javascript/src/frontend/collab_forms/forms/project_collabnotes/NoteFieldEditor.tsx b/javascript/src/frontend/collab_forms/forms/project_collabnotes/NoteFieldEditor.tsx
new file mode 100644
index 000000000..8a1eafaee
--- /dev/null
+++ b/javascript/src/frontend/collab_forms/forms/project_collabnotes/NoteFieldEditor.tsx
@@ -0,0 +1,87 @@
+import { useSortable } from "@dnd-kit/sortable";
+import { CSS } from "@dnd-kit/utilities";
+import RichTextEditor from "../../rich_text_editor";
+import ImageField from "./ImageField";
+import type { NoteField } from "./types";
+import type { HocuspocusProvider } from "@hocuspocus/provider";
+
+interface NoteFieldEditorProps {
+ field: NoteField;
+ provider: HocuspocusProvider;
+ connected: boolean;
+ onDelete: () => void;
+ onUploadImage?: (fieldId: string, file: File) => void;
+ uploadingFieldId?: string | null;
+}
+
+export default function NoteFieldEditor({
+ field,
+ provider,
+ connected,
+ onDelete,
+ onUploadImage,
+ uploadingFieldId,
+}: NoteFieldEditorProps) {
+ const {
+ attributes,
+ listeners,
+ setNodeRef,
+ transform,
+ transition,
+ isDragging,
+ } = useSortable({
+ id: field.id,
+ data: { type: "field", field },
+ });
+
+ const style = {
+ transform: CSS.Transform.toString(transform),
+ transition,
+ opacity: isDragging ? 0.5 : 1,
+ };
+
+ return (
+
+
+
+
+
+ { e.stopPropagation(); e.preventDefault(); onDelete(); }}
+ className="btn btn-sm btn-danger"
+ title="Delete field"
+ style={{ padding: "4px 8px" }}
+ >
+
+
+
+ {field.fieldType === "rich_text" ? "Text" : "Image"}
+
+
+ {field.fieldType === "rich_text" ? (
+
+
+
+ ) : (
+
onUploadImage(field.id, file) : undefined
+ }
+ uploading={uploadingFieldId === field.id}
+ />
+ )}
+
+ );
+}
diff --git a/javascript/src/frontend/collab_forms/forms/project_collabnotes/NoteTreeView.tsx b/javascript/src/frontend/collab_forms/forms/project_collabnotes/NoteTreeView.tsx
new file mode 100644
index 000000000..670578f93
--- /dev/null
+++ b/javascript/src/frontend/collab_forms/forms/project_collabnotes/NoteTreeView.tsx
@@ -0,0 +1,301 @@
+import { useState, useMemo, useCallback } from "react";
+import {
+ DndContext,
+ DragOverlay,
+ closestCenter,
+ KeyboardSensor,
+ PointerSensor,
+ useSensor,
+ useSensors,
+} from "@dnd-kit/core";
+import {
+ SortableContext,
+ sortableKeyboardCoordinates,
+ verticalListSortingStrategy,
+} from "@dnd-kit/sortable";
+import { useNoteMutations } from "./hooks/useNoteMutations";
+import { useTreeDnd } from "./hooks/useTreeDnd";
+import { useTreeSync } from "./hooks/useTreeSync";
+import SortableTreeItem from "./SortableTreeItem";
+import TreeItem from "./TreeItem";
+import CreateModal from "./CreateModal";
+import DeleteConfirmModal from "./DeleteConfirmModal";
+import type { NoteTreeNode, FlatNote } from "./types";
+import "./tree.css";
+
+interface NoteTreeViewProps {
+ projectId: number;
+ selectedId: number | null;
+ onSelect: (id: number | null) => void;
+}
+
+export default function NoteTreeView({
+ projectId,
+ selectedId,
+ onSelect,
+}: NoteTreeViewProps) {
+ const {
+ tree,
+ flatNodes,
+ loading,
+ addNode,
+ removeNodes,
+ updateNode,
+ } = useTreeSync({ projectId });
+
+ const { createNote, createFolder, deleteNote, renameNote, moveNote } =
+ useNoteMutations();
+
+ const [showCreateModal, setShowCreateModal] = useState(false);
+ const [createType, setCreateType] = useState<"note" | "folder">("note");
+ const [createParentId, setCreateParentId] = useState(null);
+ const [pendingDeleteItem, setPendingDeleteItem] = useState(null);
+
+ const sensors = useSensors(
+ useSensor(PointerSensor, {
+ activationConstraint: { distance: 8 },
+ }),
+ useSensor(KeyboardSensor, {
+ coordinateGetter: sortableKeyboardCoordinates,
+ })
+ );
+
+ /** Called after any tree mutation to sync Yjs state */
+ const onTreeMutated = useCallback(() => {
+ // Tree changes propagate through Yjs automatically via addNode/removeNodes/updateNode
+ }, []);
+
+ const {
+ dragState,
+ handleDragStart,
+ handleDragOver,
+ handleDragEnd: rawDragEnd,
+ handleDragCancel,
+ } = useTreeDnd({ flatNodes, moveNote, onTreeMutated });
+
+ // Wrap handleDragEnd to also update Yjs tree
+ const handleDragEnd = useCallback(
+ async (event: any) => {
+ const { active, over } = event;
+ const { overId, dropPosition } = dragState;
+
+ if (over && overId !== null && dropPosition !== null) {
+ const activeId = active.id as number;
+ const overNode = flatNodes.find((n) => n.id === overId);
+ if (overNode) {
+ const newParentId = dropPosition === "inside" ? overId : overNode.parentId;
+ // Update the Yjs node with new parent/position
+ // (the actual DB mutation happens in rawDragEnd)
+ updateNode(activeId, { parentId: newParentId });
+ }
+ }
+
+ await rawDragEnd(event);
+ },
+ [dragState, flatNodes, rawDragEnd, updateNode]
+ );
+
+ const allItemIds = useMemo(() => flatNodes.map((n) => n.id), [flatNodes]);
+
+ const activeItem = useMemo(() => {
+ if (!dragState.activeId) return null;
+ const findItem = (nodes: NoteTreeNode[]): NoteTreeNode | null => {
+ for (const node of nodes) {
+ if (node.id === dragState.activeId) return node;
+ if (node.children.length > 0) {
+ const found = findItem(node.children);
+ if (found) return found;
+ }
+ }
+ return null;
+ };
+ return findItem(tree);
+ }, [dragState.activeId, tree]);
+
+ const handleCreate = async (title: string) => {
+ let newId: number;
+ if (createType === "folder") {
+ newId = await createFolder(projectId, createParentId, title);
+ } else {
+ newId = await createNote(projectId, createParentId, title);
+ onSelect(newId);
+ }
+
+ // Add node to Yjs tree for real-time sync
+ const position = flatNodes
+ .filter((n) => n.parentId === createParentId)
+ .reduce((max, n) => Math.max(max, n.position), -1000) + 1000;
+
+ addNode({
+ id: newId,
+ title,
+ nodeType: createType,
+ parentId: createParentId,
+ position,
+ });
+
+ setShowCreateModal(false);
+ };
+
+ const requestDelete = (item: NoteTreeNode) => {
+ setPendingDeleteItem(item);
+ };
+
+ const handleConfirmDelete = async () => {
+ if (!pendingDeleteItem) return;
+ const id = pendingDeleteItem.id;
+ setPendingDeleteItem(null);
+ if (selectedId === id) onSelect(null);
+
+ // Collect all descendant IDs
+ const idsToRemove = new Set();
+ const collectIds = (nodes: NoteTreeNode[]) => {
+ for (const node of nodes) {
+ idsToRemove.add(node.id);
+ collectIds(node.children);
+ }
+ };
+ const findNode = (nodes: NoteTreeNode[]): NoteTreeNode | null => {
+ for (const n of nodes) {
+ if (n.id === id) return n;
+ const found = findNode(n.children);
+ if (found) return found;
+ }
+ return null;
+ };
+ const targetNode = findNode(tree);
+ if (targetNode) collectIds([targetNode]);
+ else idsToRemove.add(id);
+
+ await deleteNote(id);
+ removeNodes(idsToRemove);
+ };
+
+ const handleCancelDelete = () => {
+ setPendingDeleteItem(null);
+ };
+
+ const handleRename = async (id: number, title: string) => {
+ await renameNote(id, title);
+ updateNode(id, { title });
+ };
+
+ const handleCreateChild = (parentId: number, type: "note" | "folder") => {
+ setCreateType(type);
+ setCreateParentId(parentId);
+ setShowCreateModal(true);
+ };
+
+ const openCreateModal = (type: "note" | "folder") => {
+ setCreateType(type);
+ setCreateParentId(null);
+ setShowCreateModal(true);
+ };
+
+ if (loading) {
+ return (
+
+
+ Loading...
+
+
Loading notes...
+
+ );
+ }
+
+ return (
+
+
+ openCreateModal("note")}
+ title="New Note"
+ >
+ Note
+
+ openCreateModal("folder")}
+ title="New Folder"
+ >
+ Folder
+
+
+ (window.location.href = `/rolodex/ajax/project/${projectId}/notes/export`)
+ }
+ title="Download all notes as ZIP"
+ >
+
+
+
+
+
+
+
+ {tree.map((item) => (
+
+ ))}
+ {tree.length === 0 && (
+
+ No notes yet. Create one using the buttons above.
+
+ )}
+
+
+
+
+ {activeItem && (
+
+ {}}
+ onRequestDelete={() => {}}
+ onRename={() => {}}
+ onCreateChild={() => {}}
+ />
+
+ )}
+
+
+
+
setShowCreateModal(false)}
+ onCreate={handleCreate}
+ />
+
+
+
+ );
+}
diff --git a/javascript/src/frontend/collab_forms/forms/project_collabnotes/SortableTreeItem.tsx b/javascript/src/frontend/collab_forms/forms/project_collabnotes/SortableTreeItem.tsx
new file mode 100644
index 000000000..98996e373
--- /dev/null
+++ b/javascript/src/frontend/collab_forms/forms/project_collabnotes/SortableTreeItem.tsx
@@ -0,0 +1,74 @@
+import { useSortable } from "@dnd-kit/sortable";
+import TreeItem from "./TreeItem";
+import { NoteTreeNode, DropPosition, DragState } from "./types";
+
+interface SortableTreeItemProps {
+ item: NoteTreeNode;
+ depth: number;
+ selectedId: number | null;
+ onSelect: (id: number | null) => void;
+ onRequestDelete: (item: NoteTreeNode) => void;
+ onRename: (id: number, title: string) => void;
+ onCreateChild: (parentId: number, type: "note" | "folder") => void;
+ dragState: DragState;
+}
+
+export default function SortableTreeItem({
+ item,
+ depth,
+ selectedId,
+ onSelect,
+ onRequestDelete,
+ onRename,
+ onCreateChild,
+ dragState,
+}: SortableTreeItemProps) {
+ const {
+ attributes,
+ listeners,
+ setNodeRef,
+ isDragging,
+ } = useSortable({ id: item.id });
+
+ const style = { opacity: isDragging ? 0 : 1 };
+
+ const isDropTarget = dragState.overId === item.id;
+ const dropPosition: DropPosition | null = isDropTarget
+ ? dragState.dropPosition
+ : null;
+
+ const renderChildren = (children: NoteTreeNode[], childDepth: number) => {
+ return children.map((child) => (
+
+ ));
+ };
+
+ return (
+
+
+
+ );
+}
diff --git a/javascript/src/frontend/collab_forms/forms/project_collabnotes/TreeItem.tsx b/javascript/src/frontend/collab_forms/forms/project_collabnotes/TreeItem.tsx
new file mode 100644
index 000000000..f9376f553
--- /dev/null
+++ b/javascript/src/frontend/collab_forms/forms/project_collabnotes/TreeItem.tsx
@@ -0,0 +1,182 @@
+import { useState } from "react";
+import { NoteTreeNode, DropPosition } from "./types";
+
+interface TreeItemProps {
+ item: NoteTreeNode;
+ depth: number;
+ selectedId: number | null;
+ onSelect: (id: number | null) => void;
+ onRequestDelete: (item: NoteTreeNode) => void;
+ onRename: (id: number, title: string) => void;
+ onCreateChild: (parentId: number, type: "note" | "folder") => void;
+ isDragging?: boolean;
+ dropPosition?: DropPosition | null;
+ dragHandleProps?: Record;
+ dragAttributes?: Record;
+ renderChildren?: (children: NoteTreeNode[], depth: number) => React.ReactNode;
+}
+
+export default function TreeItem({
+ item,
+ depth,
+ selectedId,
+ onSelect,
+ onRequestDelete,
+ onRename,
+ onCreateChild,
+ isDragging = false,
+ dropPosition = null,
+ dragHandleProps,
+ dragAttributes,
+ renderChildren,
+}: TreeItemProps) {
+ const [expanded, setExpanded] = useState(true);
+ const [isRenaming, setIsRenaming] = useState(false);
+ const [renameValue, setRenameValue] = useState(item.title);
+
+ const isFolder = item.nodeType === "folder";
+ const isSelected = selectedId === item.id;
+ const hasChildren = item.children.length > 0;
+
+ const handleClick = () => {
+ if (isFolder) {
+ setExpanded(!expanded);
+ } else {
+ onSelect(item.id);
+ }
+ };
+
+ const handleRenameSubmit = () => {
+ if (renameValue.trim() && renameValue !== item.title) {
+ onRename(item.id, renameValue.trim());
+ }
+ setIsRenaming(false);
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === "Enter") handleRenameSubmit();
+ else if (e.key === "Escape") {
+ setRenameValue(item.title);
+ setIsRenaming(false);
+ }
+ };
+
+ const dropClass = dropPosition ? `tree-item-drop-${dropPosition}` : "";
+
+ const actionButtonClass = isSelected
+ ? "btn btn-sm p-0 me-1 text-white"
+ : "btn btn-link btn-sm p-0 me-1";
+
+ const deleteButtonClass = isSelected
+ ? "btn btn-sm p-0 text-white"
+ : "btn btn-link btn-sm p-0 text-danger";
+
+ return (
+
+
+
+ {isFolder && hasChildren && (
+ expanded
+ ?
+ :
+ )}
+
+
+
+ {isFolder ? (
+
+ ) : (
+
+ )}
+
+
+ {isRenaming ? (
+ setRenameValue(e.target.value)}
+ onBlur={handleRenameSubmit}
+ onKeyDown={handleKeyDown}
+ onClick={(e) => e.stopPropagation()}
+ autoFocus
+ style={{ maxWidth: "150px" }}
+ />
+ ) : (
+
+ {item.title}
+
+ )}
+
+ {!isRenaming && (
+
+ {isFolder && (
+ <>
+ { e.stopPropagation(); onCreateChild(item.id, "note"); setExpanded(true); }}
+ title="Add note"
+ >
+
+
+ { e.stopPropagation(); onCreateChild(item.id, "folder"); setExpanded(true); }}
+ title="Add subfolder"
+ >
+
+
+ >
+ )}
+ { e.stopPropagation(); setRenameValue(item.title); setIsRenaming(true); }}
+ title="Rename"
+ >
+
+
+ { e.stopPropagation(); onRequestDelete(item); }}
+ title="Delete"
+ >
+
+
+
+ )}
+
+
+ {isFolder && expanded && item.children.length > 0 && (
+
+ {renderChildren
+ ? renderChildren(item.children, depth + 1)
+ : item.children.map((child) => (
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/javascript/src/frontend/collab_forms/forms/project_collabnotes/hooks/useFieldMutations.ts b/javascript/src/frontend/collab_forms/forms/project_collabnotes/hooks/useFieldMutations.ts
new file mode 100644
index 000000000..9451f6d35
--- /dev/null
+++ b/javascript/src/frontend/collab_forms/forms/project_collabnotes/hooks/useFieldMutations.ts
@@ -0,0 +1,162 @@
+import { useCallback } from "react";
+
+const CREATE_RICH_TEXT_FIELD_MUTATION = `
+ mutation CreateRichTextField($noteId: bigint!, $position: Int!) {
+ insert_projectCollabNoteField_one(object: {
+ noteId: $noteId,
+ fieldType: "rich_text",
+ content: "",
+ position: $position
+ }) {
+ id
+ position
+ }
+ }
+`;
+
+const CREATE_IMAGE_FIELD_MUTATION = `
+ mutation CreateImageField($noteId: bigint!, $position: Int!) {
+ insert_projectCollabNoteField_one(object: {
+ noteId: $noteId,
+ fieldType: "image",
+ content: "",
+ position: $position
+ }) {
+ id
+ position
+ }
+ }
+`;
+
+const DELETE_FIELD_MUTATION = `
+ mutation DeleteProjectCollabNoteField($id: bigint!) {
+ delete_projectCollabNoteField_by_pk(id: $id) {
+ id
+ }
+ }
+`;
+
+function buildReorderMutation(
+ fields: Array<{ id: string; position: number }>
+): { query: string; variables: Record } {
+ const mutations = fields.map(
+ (field, index) =>
+ `field${index}: update_projectCollabNoteField_by_pk(
+ pk_columns: { id: $id${index} },
+ _set: { position: $position${index} }
+ ) { id }`
+ );
+
+ const variables: Record = {};
+ const variableDeclarations = fields.map((field, index) => {
+ variables[`id${index}`] = parseInt(field.id);
+ variables[`position${index}`] = field.position;
+ return `$id${index}: bigint!, $position${index}: Int!`;
+ });
+
+ const query = `
+ mutation ReorderFields(${variableDeclarations.join(", ")}) {
+ ${mutations.join("\n ")}
+ }
+ `;
+
+ return { query, variables };
+}
+
+const GET_MAX_POSITION = `
+ query GetMaxFieldPosition($noteId: bigint!) {
+ projectCollabNoteField_aggregate(
+ where: { noteId: { _eq: $noteId } }
+ ) {
+ aggregate {
+ max {
+ position
+ }
+ }
+ }
+ }
+`;
+
+function getJwt(): string {
+ return document.getElementById("yjs-jwt")?.innerHTML ?? "";
+}
+
+async function graphqlMutate(query: string, variables: Record) {
+ const response = await fetch("/v1/graphql", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Authorization": `Bearer ${getJwt()}`,
+ },
+ body: JSON.stringify({ query, variables }),
+ });
+ const data = await response.json();
+ if (data.errors) {
+ throw new Error(data.errors[0]?.message || "GraphQL error");
+ }
+ return data.data;
+}
+
+export function useFieldMutations() {
+ const getNextPosition = useCallback(
+ async (noteId: number): Promise => {
+ const data = await graphqlMutate(GET_MAX_POSITION, { noteId });
+ const maxPos =
+ data.projectCollabNoteField_aggregate.aggregate.max.position;
+ return maxPos !== null ? maxPos + 1 : 0;
+ },
+ []
+ );
+
+ const createRichTextField = useCallback(
+ async (noteId: number): Promise<{ id: string; position: number }> => {
+ const position = await getNextPosition(noteId);
+ const data = await graphqlMutate(CREATE_RICH_TEXT_FIELD_MUTATION, {
+ noteId,
+ position,
+ });
+ return {
+ id: data.insert_projectCollabNoteField_one.id.toString(),
+ position: data.insert_projectCollabNoteField_one.position,
+ };
+ },
+ [getNextPosition]
+ );
+
+ const createImageField = useCallback(
+ async (noteId: number): Promise<{ id: string; position: number }> => {
+ const position = await getNextPosition(noteId);
+ const data = await graphqlMutate(CREATE_IMAGE_FIELD_MUTATION, {
+ noteId,
+ position,
+ });
+ return {
+ id: data.insert_projectCollabNoteField_one.id.toString(),
+ position: data.insert_projectCollabNoteField_one.position,
+ };
+ },
+ [getNextPosition]
+ );
+
+ const deleteField = useCallback(async (id: string): Promise => {
+ await graphqlMutate(DELETE_FIELD_MUTATION, { id: parseInt(id) });
+ }, []);
+
+ const reorderFields = useCallback(
+ async (
+ fields: Array<{ id: string; position: number; noteId: number }>
+ ): Promise => {
+ if (fields.length === 0) return;
+ const { query, variables } = buildReorderMutation(fields);
+ await graphqlMutate(query, variables);
+ },
+ []
+ );
+
+ return {
+ createRichTextField,
+ createImageField,
+ deleteField,
+ reorderFields,
+ };
+}
diff --git a/javascript/src/frontend/collab_forms/forms/project_collabnotes/hooks/useImageUpload.ts b/javascript/src/frontend/collab_forms/forms/project_collabnotes/hooks/useImageUpload.ts
new file mode 100644
index 000000000..5620615b3
--- /dev/null
+++ b/javascript/src/frontend/collab_forms/forms/project_collabnotes/hooks/useImageUpload.ts
@@ -0,0 +1,132 @@
+import { useState } from "react";
+
+interface UploadImageResponse {
+ result: string;
+ id: string;
+ imageUrl: string;
+ position: number;
+ message?: string;
+}
+
+interface UploadToFieldResponse {
+ result: string;
+ imageUrl: string;
+ message?: string;
+}
+
+export function useImageUpload() {
+ const [uploading, setUploading] = useState(false);
+ const [uploadingFieldId, setUploadingFieldId] = useState(null);
+ const [error, setError] = useState(null);
+
+ const uploadImage = async (noteId: number, file: File): Promise => {
+ setUploading(true);
+ setError(null);
+
+ try {
+ if (!file.type.startsWith("image/")) {
+ throw new Error(`Invalid file type: ${file.type}`);
+ }
+
+ const maxSize = 10 * 1024 * 1024;
+ if (file.size > maxSize) {
+ throw new Error(`File too large: ${(file.size / 1024 / 1024).toFixed(1)} MB. Maximum: 10 MB`);
+ }
+
+ const formData = new FormData();
+ formData.append("image", file);
+
+ const csrf = document.cookie
+ .split("; ")
+ .find((row) => row.startsWith("csrftoken="))
+ ?.split("=")[1];
+
+ const response = await fetch(`/rolodex/ajax/note/${noteId}/field/image`, {
+ method: "POST",
+ body: formData,
+ headers: { "X-CSRFToken": csrf || "" },
+ });
+
+ if (!response.ok) {
+ const data = await response.json();
+ throw new Error(data.message || "Failed to upload image");
+ }
+
+ const data: UploadImageResponse = await response.json();
+ if (data.result !== "success") throw new Error(data.message || "Upload failed");
+ return data;
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Unknown error");
+ return null;
+ } finally {
+ setUploading(false);
+ }
+ };
+
+ const uploadToField = async (
+ noteId: number,
+ fieldId: string,
+ file: File
+ ): Promise => {
+ setUploading(true);
+ setUploadingFieldId(fieldId);
+ setError(null);
+
+ try {
+ if (!file.type.startsWith("image/")) {
+ throw new Error(`Invalid file type: ${file.type}`);
+ }
+
+ const maxSize = 10 * 1024 * 1024;
+ if (file.size > maxSize) {
+ throw new Error(`File too large: ${(file.size / 1024 / 1024).toFixed(1)} MB. Maximum: 10 MB`);
+ }
+
+ const formData = new FormData();
+ formData.append("image", file);
+
+ const csrf = document.cookie
+ .split("; ")
+ .find((row) => row.startsWith("csrftoken="))
+ ?.split("=")[1];
+
+ const response = await fetch(`/rolodex/ajax/note/${noteId}/field/${fieldId}/image`, {
+ method: "POST",
+ body: formData,
+ headers: { "X-CSRFToken": csrf || "" },
+ });
+
+ if (!response.ok) {
+ const data = await response.json();
+ throw new Error(data.message || "Failed to upload image");
+ }
+
+ const data: UploadToFieldResponse = await response.json();
+ if (data.result !== "success") throw new Error(data.message || "Upload failed");
+ return data;
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Unknown error");
+ return null;
+ } finally {
+ setUploading(false);
+ setUploadingFieldId(null);
+ }
+ };
+
+ const handlePaste = async (noteId: number, event: ClipboardEvent): Promise => {
+ const items = event.clipboardData?.items;
+ if (!items) return null;
+
+ for (let i = 0; i < items.length; i++) {
+ const item = items[i];
+ if (item.type.startsWith("image/")) {
+ event.preventDefault();
+ const file = item.getAsFile();
+ if (file) return await uploadImage(noteId, file);
+ }
+ }
+ return null;
+ };
+
+ return { uploading, uploadingFieldId, error, uploadImage, uploadToField, handlePaste };
+}
diff --git a/javascript/src/frontend/collab_forms/forms/project_collabnotes/hooks/useNoteMutations.ts b/javascript/src/frontend/collab_forms/forms/project_collabnotes/hooks/useNoteMutations.ts
new file mode 100644
index 000000000..286fc5f43
--- /dev/null
+++ b/javascript/src/frontend/collab_forms/forms/project_collabnotes/hooks/useNoteMutations.ts
@@ -0,0 +1,258 @@
+import { useCallback } from "react";
+
+const CREATE_ROOT_MUTATION = `
+ mutation CreateRootProjectCollabNote(
+ $projectId: bigint!,
+ $title: String!,
+ $nodeType: String!,
+ $position: Int!
+ ) {
+ insert_projectCollabNote_one(object: {
+ projectId: $projectId,
+ title: $title,
+ nodeType: $nodeType,
+ content: "",
+ position: $position
+ }) {
+ id
+ }
+ }
+`;
+
+const CREATE_CHILD_MUTATION = `
+ mutation CreateChildProjectCollabNote(
+ $projectId: bigint!,
+ $parentId: bigint!,
+ $title: String!,
+ $nodeType: String!,
+ $position: Int!
+ ) {
+ insert_projectCollabNote_one(object: {
+ projectId: $projectId,
+ parentId: $parentId,
+ title: $title,
+ nodeType: $nodeType,
+ content: "",
+ position: $position
+ }) {
+ id
+ }
+ }
+`;
+
+const UPDATE_TITLE_MUTATION = `
+ mutation UpdateProjectCollabNoteTitle($id: bigint!, $title: String!) {
+ update_projectCollabNote_by_pk(
+ pk_columns: { id: $id },
+ _set: { title: $title }
+ ) {
+ id
+ }
+ }
+`;
+
+const DELETE_MUTATION = `
+ mutation DeleteProjectCollabNote($id: bigint!) {
+ delete_projectCollabNote_by_pk(id: $id) {
+ id
+ }
+ }
+`;
+
+const DELETE_NOTE_FIELDS_MUTATION = `
+ mutation DeleteNoteFields($noteId: bigint!) {
+ delete_projectCollabNoteField(where: { noteId: { _eq: $noteId } }) {
+ affected_rows
+ }
+ }
+`;
+
+const GET_DESCENDANTS_QUERY = `
+ query GetDescendants($id: bigint!) {
+ projectCollabNote_by_pk(id: $id) {
+ id
+ children {
+ id
+ children {
+ id
+ children {
+ id
+ children {
+ id
+ children {
+ id
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+`;
+
+const MOVE_MUTATION = `
+ mutation MoveProjectCollabNote($id: bigint!, $parentId: bigint, $position: Int!) {
+ update_projectCollabNote_by_pk(
+ pk_columns: { id: $id },
+ _set: { parentId: $parentId, position: $position }
+ ) {
+ id
+ }
+ }
+`;
+
+const GET_MAX_POSITION_ROOT = `
+ query GetMaxPositionRoot($projectId: bigint!) {
+ projectCollabNote_aggregate(
+ where: {
+ projectId: { _eq: $projectId },
+ parentId: { _is_null: true }
+ }
+ ) {
+ aggregate {
+ max {
+ position
+ }
+ }
+ }
+ }
+`;
+
+const GET_MAX_POSITION_CHILD = `
+ query GetMaxPositionChild($projectId: bigint!, $parentId: bigint!) {
+ projectCollabNote_aggregate(
+ where: {
+ projectId: { _eq: $projectId },
+ parentId: { _eq: $parentId }
+ }
+ ) {
+ aggregate {
+ max {
+ position
+ }
+ }
+ }
+ }
+`;
+
+function getJwt(): string {
+ return document.getElementById("yjs-jwt")?.innerHTML ?? "";
+}
+
+async function graphqlMutate(query: string, variables: Record) {
+ const response = await fetch("/v1/graphql", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Authorization": `Bearer ${getJwt()}`,
+ },
+ body: JSON.stringify({ query, variables }),
+ });
+ const data = await response.json();
+ if (data.errors) {
+ throw new Error(data.errors[0]?.message || "GraphQL error");
+ }
+ return data.data;
+}
+
+export function useNoteMutations() {
+ const getNextPosition = useCallback(
+ async (projectId: number, parentId: number | null): Promise => {
+ const query = parentId === null ? GET_MAX_POSITION_ROOT : GET_MAX_POSITION_CHILD;
+ const variables = parentId === null
+ ? { projectId }
+ : { projectId, parentId };
+ const data = await graphqlMutate(query, variables);
+ const maxPos =
+ data.projectCollabNote_aggregate.aggregate.max.position;
+ return maxPos !== null ? maxPos + 1000 : 0;
+ },
+ []
+ );
+
+ const createNote = useCallback(
+ async (
+ projectId: number,
+ parentId: number | null,
+ title: string
+ ): Promise => {
+ const position = await getNextPosition(projectId, parentId);
+ const mutation = parentId === null ? CREATE_ROOT_MUTATION : CREATE_CHILD_MUTATION;
+ const variables = parentId === null
+ ? { projectId, title, nodeType: "note", position }
+ : { projectId, parentId, title, nodeType: "note", position };
+ const data = await graphqlMutate(mutation, variables);
+ return data.insert_projectCollabNote_one.id;
+ },
+ [getNextPosition]
+ );
+
+ const createFolder = useCallback(
+ async (
+ projectId: number,
+ parentId: number | null,
+ title: string
+ ): Promise => {
+ const position = await getNextPosition(projectId, parentId);
+ const mutation = parentId === null ? CREATE_ROOT_MUTATION : CREATE_CHILD_MUTATION;
+ const variables = parentId === null
+ ? { projectId, title, nodeType: "folder", position }
+ : { projectId, parentId, title, nodeType: "folder", position };
+ const data = await graphqlMutate(mutation, variables);
+ return data.insert_projectCollabNote_one.id;
+ },
+ [getNextPosition]
+ );
+
+ const renameNote = useCallback(
+ async (id: number, title: string): Promise => {
+ await graphqlMutate(UPDATE_TITLE_MUTATION, { id, title });
+ },
+ []
+ );
+
+ const deleteNote = useCallback(async (id: number): Promise => {
+ interface NoteNode {
+ id: number;
+ children?: NoteNode[];
+ }
+
+ const collectIds = (node: NoteNode | null, ids: number[] = []): number[] => {
+ if (!node) return ids;
+ if (node.children) {
+ for (const child of node.children) {
+ collectIds(child, ids);
+ }
+ }
+ ids.push(node.id);
+ return ids;
+ };
+
+ const data = await graphqlMutate(GET_DESCENDANTS_QUERY, { id });
+ const idsToDelete = collectIds(data.projectCollabNote_by_pk);
+
+ for (const noteId of idsToDelete) {
+ await graphqlMutate(DELETE_NOTE_FIELDS_MUTATION, { noteId });
+ await graphqlMutate(DELETE_MUTATION, { id: noteId });
+ }
+ }, []);
+
+ const moveNote = useCallback(
+ async (
+ id: number,
+ parentId: number | null,
+ position: number
+ ): Promise => {
+ await graphqlMutate(MOVE_MUTATION, { id, parentId, position });
+ },
+ []
+ );
+
+ return {
+ createNote,
+ createFolder,
+ renameNote,
+ deleteNote,
+ moveNote,
+ };
+}
diff --git a/javascript/src/frontend/collab_forms/forms/project_collabnotes/hooks/useTreeDnd.ts b/javascript/src/frontend/collab_forms/forms/project_collabnotes/hooks/useTreeDnd.ts
new file mode 100644
index 000000000..b68e73bb0
--- /dev/null
+++ b/javascript/src/frontend/collab_forms/forms/project_collabnotes/hooks/useTreeDnd.ts
@@ -0,0 +1,153 @@
+import { useState, useCallback } from "react";
+import { DragEndEvent, DragOverEvent, DragStartEvent } from "@dnd-kit/core";
+import { FlatNote, DropPosition, DragState } from "../types";
+
+interface UseTreeDndProps {
+ flatNodes: FlatNote[];
+ moveNote: (id: number, parentId: number | null, position: number) => Promise;
+ onTreeMutated: () => void;
+}
+
+export function useTreeDnd({ flatNodes, moveNote, onTreeMutated }: UseTreeDndProps) {
+ const [dragState, setDragState] = useState({
+ activeId: null,
+ overId: null,
+ dropPosition: null,
+ });
+
+ const isDescendant = useCallback(
+ (nodeId: number, ancestorId: number): boolean => {
+ let current = flatNodes.find((n) => n.id === nodeId);
+ while (current?.parentId !== null) {
+ if (current.parentId === ancestorId) return true;
+ current = flatNodes.find((n) => n.id === current!.parentId);
+ }
+ return false;
+ },
+ [flatNodes]
+ );
+
+ const isValidDrop = useCallback(
+ (activeId: number, overId: number, dropPosition: DropPosition): boolean => {
+ if (activeId === overId) return false;
+ const activeNode = flatNodes.find((n) => n.id === activeId);
+ if (!activeNode) return false;
+
+ if (dropPosition === "inside") {
+ const overNode = flatNodes.find((n) => n.id === overId);
+ if (!overNode || overNode.nodeType !== "folder") return false;
+ if (isDescendant(overId, activeId)) return false;
+ }
+
+ return true;
+ },
+ [flatNodes, isDescendant]
+ );
+
+ const calculateNewPosition = useCallback(
+ (overId: number, dropPosition: DropPosition, newParentId: number | null): number => {
+ const siblings = flatNodes
+ .filter((n) => n.parentId === newParentId)
+ .sort((a, b) => a.position - b.position);
+
+ if (siblings.length === 0) return 0;
+
+ const overIndex = siblings.findIndex((n) => n.id === overId);
+
+ if (dropPosition === "inside") {
+ const children = flatNodes
+ .filter((n) => n.parentId === overId)
+ .sort((a, b) => a.position - b.position);
+ if (children.length === 0) return 0;
+ return children[children.length - 1].position + 1000;
+ }
+
+ if (dropPosition === "before") {
+ if (overIndex === 0) return siblings[0].position - 1000;
+ const prevPos = siblings[overIndex - 1].position;
+ const currPos = siblings[overIndex].position;
+ return Math.floor((prevPos + currPos) / 2);
+ }
+
+ if (overIndex === siblings.length - 1) return siblings[overIndex].position + 1000;
+ const currPos = siblings[overIndex].position;
+ const nextPos = siblings[overIndex + 1].position;
+ return Math.floor((currPos + nextPos) / 2);
+ },
+ [flatNodes]
+ );
+
+ const handleDragStart = useCallback((event: DragStartEvent) => {
+ setDragState({ activeId: event.active.id as number, overId: null, dropPosition: null });
+ }, []);
+
+ const handleDragOver = useCallback(
+ (event: DragOverEvent) => {
+ const { active, over } = event;
+ if (!over) {
+ setDragState((prev) => ({ ...prev, overId: null, dropPosition: null }));
+ return;
+ }
+
+ const overId = over.id as number;
+ const activeId = active.id as number;
+
+ if (activeId === overId) {
+ setDragState((prev) => ({ ...prev, overId: null, dropPosition: null }));
+ return;
+ }
+
+ const overNode = flatNodes.find((n) => n.id === overId);
+ let dropPosition: DropPosition =
+ overNode?.nodeType === "folder" ? "inside" : "after";
+
+ if (!isValidDrop(activeId, overId, dropPosition)) {
+ if (dropPosition === "inside") {
+ dropPosition = "after";
+ if (!isValidDrop(activeId, overId, dropPosition)) {
+ setDragState((prev) => ({ ...prev, overId: null, dropPosition: null }));
+ return;
+ }
+ } else {
+ setDragState((prev) => ({ ...prev, overId: null, dropPosition: null }));
+ return;
+ }
+ }
+
+ setDragState((prev) => ({ ...prev, overId, dropPosition }));
+ },
+ [flatNodes, isValidDrop]
+ );
+
+ const handleDragEnd = useCallback(
+ async (event: DragEndEvent) => {
+ const { active, over } = event;
+ const { overId, dropPosition } = dragState;
+
+ setDragState({ activeId: null, overId: null, dropPosition: null });
+
+ if (!over || overId === null || dropPosition === null) return;
+
+ const activeId = active.id as number;
+ const overNode = flatNodes.find((n) => n.id === overId);
+ if (!overNode) return;
+
+ const newParentId = dropPosition === "inside" ? overId : overNode.parentId;
+ const newPosition = calculateNewPosition(overId, dropPosition, newParentId);
+
+ try {
+ await moveNote(activeId, newParentId, newPosition);
+ onTreeMutated();
+ } catch (error) {
+ console.error("Failed to move note:", error);
+ }
+ },
+ [dragState, flatNodes, calculateNewPosition, moveNote, onTreeMutated]
+ );
+
+ const handleDragCancel = useCallback(() => {
+ setDragState({ activeId: null, overId: null, dropPosition: null });
+ }, []);
+
+ return { dragState, handleDragStart, handleDragOver, handleDragEnd, handleDragCancel };
+}
diff --git a/javascript/src/frontend/collab_forms/forms/project_collabnotes/hooks/useTreeSync.ts b/javascript/src/frontend/collab_forms/forms/project_collabnotes/hooks/useTreeSync.ts
new file mode 100644
index 000000000..c72cc1120
--- /dev/null
+++ b/javascript/src/frontend/collab_forms/forms/project_collabnotes/hooks/useTreeSync.ts
@@ -0,0 +1,186 @@
+import { useEffect, useRef, useCallback, useState } from "react";
+import {
+ HocuspocusProvider,
+ HocuspocusProviderWebsocket,
+} from "@hocuspocus/provider";
+import * as Y from "yjs";
+import type { FlatNote, NoteTreeNode } from "../types";
+
+interface UseTreeSyncOptions {
+ projectId: number;
+}
+
+/**
+ * Hook for real-time tree synchronization across clients using Yjs.
+ *
+ * Instead of broadcasting "tree-changed" messages and re-fetching from GraphQL,
+ * the tree structure is stored in a Yjs shared Y.Array. When any client modifies
+ * the tree (create/delete/rename/move), it updates both the database (via GraphQL)
+ * and the Yjs array. Other clients see changes via Yjs sync automatically.
+ */
+export function useTreeSync({ projectId }: UseTreeSyncOptions) {
+ const providerRef = useRef(null);
+ const [flatNodes, setFlatNodes] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [connected, setConnected] = useState(false);
+
+ useEffect(() => {
+ const url = document.getElementById("yjs-url")?.innerHTML;
+ const jwt = document.getElementById("yjs-jwt")?.innerHTML;
+
+ if (!url || !jwt) {
+ console.warn("useTreeSync: Missing yjs-url or yjs-jwt elements");
+ setLoading(false);
+ return;
+ }
+
+ const websocketProvider = new HocuspocusProviderWebsocket({
+ url,
+ autoConnect: false,
+ });
+
+ const provider = new HocuspocusProvider({
+ websocketProvider,
+ name: `project_tree_sync/${projectId}`,
+ token: jwt,
+ onSynced() {
+ setConnected(true);
+ setLoading(false);
+ // Read initial tree from Yjs doc
+ const treeArray = provider.document.get("tree", Y.Array) as Y.Array;
+ const nodes: FlatNote[] = [];
+ for (let i = 0; i < treeArray.length; i++) {
+ nodes.push(treeArray.get(i));
+ }
+ setFlatNodes(nodes);
+ },
+ });
+
+ providerRef.current = provider;
+
+ // Observe changes to the tree array
+ const treeArray = provider.document.get("tree", Y.Array) as Y.Array;
+ const observer = () => {
+ const nodes: FlatNote[] = [];
+ for (let i = 0; i < treeArray.length; i++) {
+ nodes.push(treeArray.get(i));
+ }
+ setFlatNodes(nodes);
+ };
+ treeArray.observeDeep(observer);
+
+ provider.attach();
+ websocketProvider.connect();
+
+ return () => {
+ treeArray.unobserveDeep(observer);
+ provider.destroy();
+ providerRef.current = null;
+ };
+ }, [projectId]);
+
+ /** Replace the entire tree array in the Yjs doc with new nodes. */
+ const updateTree = useCallback(
+ (newNodes: FlatNote[]) => {
+ const provider = providerRef.current;
+ if (!provider) return;
+ const treeArray = provider.document.get("tree", Y.Array) as Y.Array;
+ provider.document.transact(() => {
+ treeArray.delete(0, treeArray.length);
+ if (newNodes.length > 0) {
+ treeArray.push(newNodes);
+ }
+ });
+ },
+ []
+ );
+
+ /** Add a single node to the tree array. */
+ const addNode = useCallback(
+ (node: FlatNote) => {
+ const provider = providerRef.current;
+ if (!provider) return;
+ const treeArray = provider.document.get("tree", Y.Array) as Y.Array;
+ treeArray.push([node]);
+ },
+ []
+ );
+
+ /** Remove a node (and optionally its descendants) from the tree array. */
+ const removeNodes = useCallback(
+ (ids: Set) => {
+ const provider = providerRef.current;
+ if (!provider) return;
+ const treeArray = provider.document.get("tree", Y.Array) as Y.Array;
+ provider.document.transact(() => {
+ // Iterate backwards to safely delete by index
+ for (let i = treeArray.length - 1; i >= 0; i--) {
+ const node = treeArray.get(i);
+ if (ids.has(node.id)) {
+ treeArray.delete(i, 1);
+ }
+ }
+ });
+ },
+ []
+ );
+
+ /** Update a node's properties in the tree array. */
+ const updateNode = useCallback(
+ (id: number, updates: Partial) => {
+ const provider = providerRef.current;
+ if (!provider) return;
+ const treeArray = provider.document.get("tree", Y.Array) as Y.Array;
+ provider.document.transact(() => {
+ for (let i = 0; i < treeArray.length; i++) {
+ const node = treeArray.get(i);
+ if (node.id === id) {
+ treeArray.delete(i, 1);
+ treeArray.insert(i, [{ ...node, ...updates }]);
+ break;
+ }
+ }
+ });
+ },
+ []
+ );
+
+ // Build tree structure from flat nodes
+ const tree = buildTree(flatNodes);
+
+ return {
+ tree,
+ flatNodes,
+ loading,
+ connected,
+ updateTree,
+ addNode,
+ removeNodes,
+ updateNode,
+ };
+}
+
+function buildTree(flatNodes: FlatNote[]): NoteTreeNode[] {
+ const nodeMap = new Map();
+ const roots: NoteTreeNode[] = [];
+
+ for (const node of flatNodes) {
+ nodeMap.set(node.id, { ...node, children: [] });
+ }
+
+ for (const node of flatNodes) {
+ const treeNode = nodeMap.get(node.id)!;
+ if (node.parentId === null) {
+ roots.push(treeNode);
+ } else {
+ const parent = nodeMap.get(node.parentId);
+ if (parent) {
+ parent.children.push(treeNode);
+ } else {
+ roots.push(treeNode);
+ }
+ }
+ }
+
+ return roots;
+}
diff --git a/javascript/src/frontend/collab_forms/forms/project_collabnotes/index.tsx b/javascript/src/frontend/collab_forms/forms/project_collabnotes/index.tsx
new file mode 100644
index 000000000..98776ed14
--- /dev/null
+++ b/javascript/src/frontend/collab_forms/forms/project_collabnotes/index.tsx
@@ -0,0 +1,86 @@
+import ReactModal from "react-modal";
+import { createRoot, Root } from "react-dom/client";
+import { useState } from "react";
+import ErrorBoundary from "../../error_boundary";
+import NoteTreeView from "./NoteTreeView";
+import NoteEditor from "./NoteEditor";
+
+function ProjectCollabNotesContainer() {
+ const [selectedNoteId, setSelectedNoteId] = useState(null);
+
+ const projectId = parseInt(
+ document.getElementById("yjs-object-id")!.innerHTML
+ );
+
+ return (
+
+
+
+
+
+
+ {selectedNoteId ? (
+
+
+
+ ) : (
+
+
+
+
Select a note to edit, or create a new one.
+
+
+ )}
+
+
+ );
+}
+
+document.addEventListener("DOMContentLoaded", () => {
+ ReactModal.setAppElement(
+ document.querySelector("div.wrapper") as HTMLElement
+ );
+
+ const $ = (window as any).$;
+ let root: Root | null = null;
+
+ $("#id_collab_notes").on("shown.bs.tab", () => {
+ if (root !== null) return;
+ root = createRoot(document.getElementById("collab_notes_container")!);
+ root.render(
+
+
+
+ );
+ });
+
+ $("#id_collab_notes").on("hidden.bs.tab", () => {
+ if (root !== null) root.unmount();
+ root = null;
+ });
+});
diff --git a/javascript/src/frontend/collab_forms/forms/project_collabnotes/tree.css b/javascript/src/frontend/collab_forms/forms/project_collabnotes/tree.css
new file mode 100644
index 000000000..55166faca
--- /dev/null
+++ b/javascript/src/frontend/collab_forms/forms/project_collabnotes/tree.css
@@ -0,0 +1,132 @@
+.tree-item-dragging {
+ visibility: hidden;
+}
+
+.tree-item-container {
+ position: relative;
+}
+
+.tree-item-drop-before::before {
+ content: "";
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: 2px;
+ background-color: #0d6efd;
+ z-index: 10;
+}
+
+.tree-item-drop-after::after {
+ content: "";
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ height: 2px;
+ background-color: #0d6efd;
+ z-index: 10;
+}
+
+.tree-item-drop-inside > .tree-item {
+ background-color: #cfe2ff;
+ border-radius: 4px;
+}
+
+.tree-drag-overlay {
+ background: white;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+ border-radius: 4px;
+ padding: 2px;
+ opacity: 0.9;
+}
+
+.tree-item {
+ cursor: grab;
+}
+
+.tree-item:active {
+ cursor: grabbing;
+}
+
+/* Image field placeholder - addresses PR comment #16 and #27 (use CSS not inline styles) */
+.image-field-placeholder {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 2rem;
+ min-height: 150px;
+ border: 2px dashed var(--bs-border-color);
+ border-radius: 8px;
+ background-color: var(--bs-tertiary-bg);
+ cursor: pointer;
+ transition: all 0.2s ease;
+ outline: none;
+}
+
+.image-field-placeholder:focus,
+.image-field-placeholder.drag-active {
+ border-color: var(--bs-primary);
+ background-color: var(--bs-primary-bg-subtle);
+}
+
+.image-field-placeholder.uploading {
+ cursor: wait;
+}
+
+.image-field-placeholder__icon {
+ font-size: 32px;
+ color: var(--bs-secondary-color);
+ margin-bottom: 12px;
+}
+
+.image-field-placeholder.drag-active .image-field-placeholder__icon {
+ color: var(--bs-primary);
+}
+
+.image-field-placeholder__text {
+ color: var(--bs-secondary-color);
+ text-align: center;
+}
+
+.image-field-placeholder__hint {
+ font-size: 12px;
+ color: var(--bs-secondary-color);
+ margin-top: 4px;
+}
+
+/* Delete confirm modal - addresses PR comment #13 and #26 (use CSS not inline styles) */
+.collab-modal-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-color: rgba(0, 0, 0, 0.5);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1050;
+}
+
+.collab-modal-content {
+ position: relative;
+ inset: auto;
+ border: none;
+ background: none;
+ padding: 0;
+ max-width: 500px;
+ width: 100%;
+}
+
+/* Add field toolbar */
+.add-field-toolbar {
+ display: flex;
+ gap: 8px;
+ padding: 12px;
+ border-top: 1px solid var(--bs-border-color);
+ border-bottom: 1px solid var(--bs-border-color);
+ margin-bottom: 16px;
+ background-color: var(--bs-tertiary-bg);
+}
diff --git a/javascript/src/frontend/collab_forms/forms/project_collabnotes/types.ts b/javascript/src/frontend/collab_forms/forms/project_collabnotes/types.ts
new file mode 100644
index 000000000..cb9f74218
--- /dev/null
+++ b/javascript/src/frontend/collab_forms/forms/project_collabnotes/types.ts
@@ -0,0 +1,32 @@
+export interface NoteTreeNode {
+ id: number;
+ title: string;
+ nodeType: "folder" | "note";
+ parentId: number | null;
+ position: number;
+ children: NoteTreeNode[];
+}
+
+export interface FlatNote {
+ id: number;
+ title: string;
+ nodeType: "folder" | "note";
+ parentId: number | null;
+ position: number;
+}
+
+export type DropPosition = "before" | "after" | "inside";
+
+export interface DragState {
+ activeId: number | null;
+ overId: number | null;
+ dropPosition: DropPosition | null;
+}
+
+export interface NoteField {
+ id: string;
+ fieldType: "rich_text" | "image";
+ content?: string;
+ image?: string | null;
+ position: number;
+}
diff --git a/javascript/vite.config.frontend.ts b/javascript/vite.config.frontend.ts
index 1f1529ed8..975ec783c 100644
--- a/javascript/vite.config.frontend.ts
+++ b/javascript/vite.config.frontend.ts
@@ -19,7 +19,7 @@ export default defineConfig(({ mode }) => {
collab_forms_report_field:
"./src/frontend/collab_forms/forms/report_field.tsx",
collab_forms_project_collabnote:
- "./src/frontend/collab_forms/forms/project_collabnote.tsx",
+ "./src/frontend/collab_forms/forms/project_collabnotes/index.tsx",
admin_tiptap: "./src/frontend/admin_tiptap.tsx",
},
output: {
diff --git a/requirements/base.txt b/requirements/base.txt
index 2620c73c3..dd0b8c028 100644
--- a/requirements/base.txt
+++ b/requirements/base.txt
@@ -49,3 +49,4 @@ django-taggit==6.1.0
croniter==3.0.3
cvss==3.2
markdown==3.9
+markdownify==0.14.1
From 06eebb4ae7a2ca4009355028d4a702ff565555db Mon Sep 17 00:00:00 2001
From: BlaiseOfGlory <5067183+BlaiseOfGlory@users.noreply.github.com>
Date: Tue, 31 Mar 2026 13:39:02 -0500
Subject: [PATCH 02/18] Fix CodeFactor lint issues in collab notes views
- Reduce return statements in ajax_upload_note_field_image by
extracting _update_existing_image_field and _create_new_image_field
- Narrow except clause from Exception to (OSError, IntegrityError)
- Use dict .items() iteration in export_collab_notes_zip
---
ghostwriter/rolodex/views.py | 118 ++++++++++++++++++-----------------
1 file changed, 61 insertions(+), 57 deletions(-)
diff --git a/ghostwriter/rolodex/views.py b/ghostwriter/rolodex/views.py
index ff18c4a5a..5dbca182c 100644
--- a/ghostwriter/rolodex/views.py
+++ b/ghostwriter/rolodex/views.py
@@ -2332,70 +2332,74 @@ def ajax_upload_note_field_image(request, pk, field_pk=None):
{"result": "error", "message": "Invalid request method"}, status=405
)
- try:
- note = get_object_or_404(ProjectCollabNote, pk=pk)
- if not note.user_can_edit(request.user):
- return ForbiddenJsonResponse()
+ note = get_object_or_404(ProjectCollabNote, pk=pk)
+ if not note.user_can_edit(request.user):
+ return ForbiddenJsonResponse()
- form = NoteImageUploadForm(request.POST, request.FILES)
- if not form.is_valid():
- errors = form.errors.as_text()
- return JsonResponse(
- {"result": "error", "message": errors}, status=400
- )
+ form = NoteImageUploadForm(request.POST, request.FILES)
+ if not form.is_valid():
+ return JsonResponse(
+ {"result": "error", "message": form.errors.as_text()}, status=400
+ )
- image_file = form.cleaned_data["image"]
+ image_file = form.cleaned_data["image"]
+ try:
if field_pk is not None:
- field = get_object_or_404(
- ProjectCollabNoteField, pk=field_pk, note=note
- )
- if field.field_type != "image":
- return JsonResponse(
- {"result": "error", "message": "Field is not an image field"},
- status=400,
- )
- field.image = image_file
- field.save()
- image_url = request.build_absolute_uri(field.image.url)
-
- logger.info(
- "User %s uploaded image to existing field %s for note %s",
- request.user, field.id, note.id,
- )
- return JsonResponse({"result": "success", "imageUrl": image_url})
-
- max_position = (
- ProjectCollabNoteField.objects.filter(note=note)
- .order_by("-position")
- .values_list("position", flat=True)
- .first()
- ) or 0
-
- field = ProjectCollabNoteField.objects.create(
- note=note,
- field_type="image",
- image=image_file,
- position=max_position + 1,
+ return _update_existing_image_field(request, note, field_pk, image_file)
+ return _create_new_image_field(request, note, image_file)
+ except (OSError, IntegrityError):
+ logger.exception("Failed to upload image for note %s", pk)
+ return JsonResponse(
+ {"result": "error", "message": "Failed to upload image"}, status=500
)
- image_url = request.build_absolute_uri(field.image.url)
- logger.info(
- "User %s uploaded image field %s for note %s",
- request.user, field.id, note.id,
- )
- return JsonResponse({
- "result": "success",
- "id": field.id,
- "imageUrl": image_url,
- "position": field.position,
- })
- except Exception:
- logger.exception("Failed to upload image for note %s", pk)
+def _update_existing_image_field(request, note, field_pk, image_file):
+ """Update an existing image field with a new image."""
+ field = get_object_or_404(ProjectCollabNoteField, pk=field_pk, note=note)
+ if field.field_type != "image":
return JsonResponse(
- {"result": "error", "message": "Failed to upload image"}, status=500
+ {"result": "error", "message": "Field is not an image field"},
+ status=400,
)
+ field.image = image_file
+ field.save()
+ logger.info(
+ "User %s uploaded image to existing field %s for note %s",
+ request.user, field.id, note.id,
+ )
+ return JsonResponse({
+ "result": "success",
+ "imageUrl": request.build_absolute_uri(field.image.url),
+ })
+
+
+def _create_new_image_field(request, note, image_file):
+ """Create a new image field attached to a note."""
+ max_position = (
+ ProjectCollabNoteField.objects.filter(note=note)
+ .order_by("-position")
+ .values_list("position", flat=True)
+ .first()
+ ) or 0
+
+ field = ProjectCollabNoteField.objects.create(
+ note=note,
+ field_type="image",
+ image=image_file,
+ position=max_position + 1,
+ )
+ logger.info(
+ "User %s uploaded image field %s for note %s",
+ request.user, field.id, note.id,
+ )
+ return JsonResponse({
+ "result": "success",
+ "id": field.id,
+ "imageUrl": request.build_absolute_uri(field.image.url),
+ "position": field.position,
+ })
def _sanitize_filename(name):
@@ -2423,8 +2427,8 @@ def export_collab_notes_zip(request, pk):
notes_by_parent = {}
for note in notes:
notes_by_parent.setdefault(note.parent_id, []).append(note)
- for parent_id in notes_by_parent:
- notes_by_parent[parent_id].sort(key=lambda n: n.position)
+ for _parent_id, children in notes_by_parent.items():
+ children.sort(key=lambda n: n.position)
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf:
From 75ed837e2f2a7263b2bb08bd2746d2fd091ff701 Mon Sep 17 00:00:00 2001
From: BlaiseOfGlory <5067183+BlaiseOfGlory@users.noreply.github.com>
Date: Wed, 1 Apr 2026 09:37:46 -0500
Subject: [PATCH 03/18] Add @dnd-kit dependencies for collab notes
drag-and-drop
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Missing from initial commit — required by NoteTreeView and
NoteEditor field reordering.
---
javascript/package.json | 3 +++
1 file changed, 3 insertions(+)
diff --git a/javascript/package.json b/javascript/package.json
index bfe29ab78..588f3c812 100644
--- a/javascript/package.json
+++ b/javascript/package.json
@@ -28,6 +28,9 @@
},
"dependencies": {
"@apollo/client": "^3.13.1",
+ "@dnd-kit/core": "^6.3.1",
+ "@dnd-kit/sortable": "^10.0.0",
+ "@dnd-kit/utilities": "^3.2.2",
"@fortawesome/fontawesome-svg-core": "^6.7.2",
"@fortawesome/free-regular-svg-icons": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.7.2",
From 1ae04ae83de30a9153ce52d9420658f07a03c8cb Mon Sep 17 00:00:00 2001
From: BlaiseOfGlory <5067183+BlaiseOfGlory@users.noreply.github.com>
Date: Wed, 1 Apr 2026 10:16:43 -0500
Subject: [PATCH 04/18] Fix note creation, modal positioning, and drag
indicators
- Enable allow_aggregations in Hasura select permissions for both
collab note tables (fixes _aggregate query 404)
- Use ReactModal style prop for overlay positioning instead of
className (fixes modals rendering at page bottom)
- Calculate drop position from cursor Y relative to item rect
(shows before/after/inside indicators during drag)
---
.../public_rolodex_projectcollabnote.yaml | 2 +
...public_rolodex_projectcollabnotefield.yaml | 2 +
.../forms/project_collabnotes/CreateModal.tsx | 28 +++++++++++--
.../DeleteConfirmModal.tsx | 28 +++++++++++--
.../project_collabnotes/hooks/useTreeDnd.ts | 39 ++++++++++++++++++-
5 files changed, 91 insertions(+), 8 deletions(-)
diff --git a/hasura-docker/metadata/databases/default/tables/public_rolodex_projectcollabnote.yaml b/hasura-docker/metadata/databases/default/tables/public_rolodex_projectcollabnote.yaml
index 0f9bc15a8..94d304b3a 100644
--- a/hasura-docker/metadata/databases/default/tables/public_rolodex_projectcollabnote.yaml
+++ b/hasura-docker/metadata/databases/default/tables/public_rolodex_projectcollabnote.yaml
@@ -81,9 +81,11 @@ select_permissions:
permission:
columns: '*'
filter: {}
+ allow_aggregations: true
- role: user
permission:
columns: '*'
+ allow_aggregations: true
filter:
project:
_or:
diff --git a/hasura-docker/metadata/databases/default/tables/public_rolodex_projectcollabnotefield.yaml b/hasura-docker/metadata/databases/default/tables/public_rolodex_projectcollabnotefield.yaml
index 93a1df8c6..90a3a66a3 100644
--- a/hasura-docker/metadata/databases/default/tables/public_rolodex_projectcollabnotefield.yaml
+++ b/hasura-docker/metadata/databases/default/tables/public_rolodex_projectcollabnotefield.yaml
@@ -63,9 +63,11 @@ select_permissions:
permission:
columns: '*'
filter: {}
+ allow_aggregations: true
- role: user
permission:
columns: '*'
+ allow_aggregations: true
filter:
note:
project:
diff --git a/javascript/src/frontend/collab_forms/forms/project_collabnotes/CreateModal.tsx b/javascript/src/frontend/collab_forms/forms/project_collabnotes/CreateModal.tsx
index 8d8f31be3..b6444c991 100644
--- a/javascript/src/frontend/collab_forms/forms/project_collabnotes/CreateModal.tsx
+++ b/javascript/src/frontend/collab_forms/forms/project_collabnotes/CreateModal.tsx
@@ -8,6 +8,30 @@ interface CreateModalProps {
onCreate: (title: string) => void;
}
+const OVERLAY_STYLE: ReactModal.Styles = {
+ overlay: {
+ position: "fixed",
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ backgroundColor: "rgba(0, 0, 0, 0.5)",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ zIndex: 1050,
+ },
+ content: {
+ position: "relative",
+ inset: "auto",
+ border: "none",
+ background: "none",
+ padding: 0,
+ maxWidth: "500px",
+ width: "100%",
+ },
+};
+
export default function CreateModal({
isOpen,
type,
@@ -40,9 +64,7 @@ export default function CreateModal({
diff --git a/javascript/src/frontend/collab_forms/forms/project_collabnotes/DeleteConfirmModal.tsx b/javascript/src/frontend/collab_forms/forms/project_collabnotes/DeleteConfirmModal.tsx
index 276848f13..edc180011 100644
--- a/javascript/src/frontend/collab_forms/forms/project_collabnotes/DeleteConfirmModal.tsx
+++ b/javascript/src/frontend/collab_forms/forms/project_collabnotes/DeleteConfirmModal.tsx
@@ -9,6 +9,30 @@ const TYPE_LABELS: Record
= {
folder: "folder",
};
+const OVERLAY_STYLE: ReactModal.Styles = {
+ overlay: {
+ position: "fixed",
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ backgroundColor: "rgba(0, 0, 0, 0.5)",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ zIndex: 1050,
+ },
+ content: {
+ position: "relative",
+ inset: "auto",
+ border: "none",
+ background: "none",
+ padding: 0,
+ maxWidth: "500px",
+ width: "100%",
+ },
+};
+
interface DeleteConfirmModalProps {
isOpen: boolean;
itemType: DeleteItemType;
@@ -31,9 +55,7 @@ export default function DeleteConfirmModal({
diff --git a/javascript/src/frontend/collab_forms/forms/project_collabnotes/hooks/useTreeDnd.ts b/javascript/src/frontend/collab_forms/forms/project_collabnotes/hooks/useTreeDnd.ts
index b68e73bb0..093e3c60c 100644
--- a/javascript/src/frontend/collab_forms/forms/project_collabnotes/hooks/useTreeDnd.ts
+++ b/javascript/src/frontend/collab_forms/forms/project_collabnotes/hooks/useTreeDnd.ts
@@ -98,10 +98,45 @@ export function useTreeDnd({ flatNodes, moveNote, onTreeMutated }: UseTreeDndPro
}
const overNode = flatNodes.find((n) => n.id === overId);
- let dropPosition: DropPosition =
- overNode?.nodeType === "folder" ? "inside" : "after";
+ if (!overNode) {
+ setDragState((prev) => ({ ...prev, overId: null, dropPosition: null }));
+ return;
+ }
+
+ // Determine drop position based on cursor Y relative to the over item
+ let dropPosition: DropPosition;
+ const overRect = over.rect;
+ const pointerY = (event.activatorEvent as PointerEvent)?.clientY;
+ const delta = event.delta?.y ?? 0;
+ const cursorY = pointerY !== undefined ? pointerY + delta : undefined;
+
+ if (overNode.nodeType === "folder") {
+ // Folders have 3 zones: top 25% = before, middle 50% = inside, bottom 25% = after
+ if (cursorY !== undefined && overRect) {
+ const relY = cursorY - overRect.top;
+ const height = overRect.height;
+ if (relY < height * 0.25) {
+ dropPosition = "before";
+ } else if (relY > height * 0.75) {
+ dropPosition = "after";
+ } else {
+ dropPosition = "inside";
+ }
+ } else {
+ dropPosition = "inside";
+ }
+ } else {
+ // Notes have 2 zones: top 50% = before, bottom 50% = after
+ if (cursorY !== undefined && overRect) {
+ const relY = cursorY - overRect.top;
+ dropPosition = relY < overRect.height * 0.5 ? "before" : "after";
+ } else {
+ dropPosition = "after";
+ }
+ }
if (!isValidDrop(activeId, overId, dropPosition)) {
+ // Fallback: try "after" if "inside" was invalid
if (dropPosition === "inside") {
dropPosition = "after";
if (!isValidDrop(activeId, overId, dropPosition)) {
From 3c5caaabef28c760de5acf99a5dd18399a280d6b Mon Sep 17 00:00:00 2001
From: BlaiseOfGlory <5067183+BlaiseOfGlory@users.noreply.github.com>
Date: Wed, 1 Apr 2026 10:23:03 -0500
Subject: [PATCH 05/18] Fix timestamp defaults and improve drag indicators
- Add RunSQL migration for DB-level NOW() defaults on timestamp
columns (Django 4.2 default= is ORM-only, Hasura bypasses it)
- Add Hasura column presets (created_at/updated_at = now()) for
insert permissions on both collab note tables
- Include auto-generated BigAutoField migration from Django
- Fix drag indicator position calculation to use active element's
translated rect center Y for accurate before/after/inside zones
---
...063_alter_projectcollabnote_id_and_more.py | 26 +++++++++++++++
.../0064_set_collabnote_timestamp_defaults.py | 33 +++++++++++++++++++
.../public_rolodex_projectcollabnote.yaml | 6 ++++
...public_rolodex_projectcollabnotefield.yaml | 6 ++++
.../project_collabnotes/hooks/useTreeDnd.ts | 18 +++++-----
5 files changed, 81 insertions(+), 8 deletions(-)
create mode 100644 ghostwriter/rolodex/migrations/0063_alter_projectcollabnote_id_and_more.py
create mode 100644 ghostwriter/rolodex/migrations/0064_set_collabnote_timestamp_defaults.py
diff --git a/ghostwriter/rolodex/migrations/0063_alter_projectcollabnote_id_and_more.py b/ghostwriter/rolodex/migrations/0063_alter_projectcollabnote_id_and_more.py
new file mode 100644
index 000000000..8d27ba10d
--- /dev/null
+++ b/ghostwriter/rolodex/migrations/0063_alter_projectcollabnote_id_and_more.py
@@ -0,0 +1,26 @@
+# Generated by Django 4.2.16 on 2026-03-31 21:26
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("rolodex", "0062_migrate_collab_notes"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="projectcollabnote",
+ name="id",
+ field=models.BigAutoField(
+ auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="projectcollabnotefield",
+ name="id",
+ field=models.BigAutoField(
+ auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
+ ),
+ ),
+ ]
diff --git a/ghostwriter/rolodex/migrations/0064_set_collabnote_timestamp_defaults.py b/ghostwriter/rolodex/migrations/0064_set_collabnote_timestamp_defaults.py
new file mode 100644
index 000000000..3bd3837b7
--- /dev/null
+++ b/ghostwriter/rolodex/migrations/0064_set_collabnote_timestamp_defaults.py
@@ -0,0 +1,33 @@
+"""Set database-level defaults for collab note timestamp columns.
+
+Django 4.2's ``default=timezone.now`` only applies at the ORM level.
+Hasura and the collab server insert rows directly, bypassing the ORM,
+so database-level defaults are required.
+"""
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("rolodex", "0063_alter_projectcollabnote_id_and_more"),
+ ]
+
+ operations = [
+ migrations.RunSQL(
+ sql="ALTER TABLE rolodex_projectcollabnote ALTER COLUMN created_at SET DEFAULT NOW();",
+ reverse_sql="ALTER TABLE rolodex_projectcollabnote ALTER COLUMN created_at DROP DEFAULT;",
+ ),
+ migrations.RunSQL(
+ sql="ALTER TABLE rolodex_projectcollabnote ALTER COLUMN updated_at SET DEFAULT NOW();",
+ reverse_sql="ALTER TABLE rolodex_projectcollabnote ALTER COLUMN updated_at DROP DEFAULT;",
+ ),
+ 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;",
+ ),
+ ]
diff --git a/hasura-docker/metadata/databases/default/tables/public_rolodex_projectcollabnote.yaml b/hasura-docker/metadata/databases/default/tables/public_rolodex_projectcollabnote.yaml
index 94d304b3a..10b19787c 100644
--- a/hasura-docker/metadata/databases/default/tables/public_rolodex_projectcollabnote.yaml
+++ b/hasura-docker/metadata/databases/default/tables/public_rolodex_projectcollabnote.yaml
@@ -47,6 +47,9 @@ insert_permissions:
- role: manager
permission:
check: {}
+ set:
+ created_at: now()
+ updated_at: now()
columns:
- title
- node_type
@@ -69,6 +72,9 @@ insert_permissions:
invites:
user_id:
_eq: X-Hasura-User-Id
+ set:
+ created_at: now()
+ updated_at: now()
columns:
- title
- node_type
diff --git a/hasura-docker/metadata/databases/default/tables/public_rolodex_projectcollabnotefield.yaml b/hasura-docker/metadata/databases/default/tables/public_rolodex_projectcollabnotefield.yaml
index 90a3a66a3..a8f7ca4a7 100644
--- a/hasura-docker/metadata/databases/default/tables/public_rolodex_projectcollabnotefield.yaml
+++ b/hasura-docker/metadata/databases/default/tables/public_rolodex_projectcollabnotefield.yaml
@@ -32,6 +32,9 @@ insert_permissions:
- role: manager
permission:
check: {}
+ set:
+ created_at: now()
+ updated_at: now()
columns:
- field_type
- content
@@ -53,6 +56,9 @@ insert_permissions:
invites:
user_id:
_eq: X-Hasura-User-Id
+ set:
+ created_at: now()
+ updated_at: now()
columns:
- field_type
- content
diff --git a/javascript/src/frontend/collab_forms/forms/project_collabnotes/hooks/useTreeDnd.ts b/javascript/src/frontend/collab_forms/forms/project_collabnotes/hooks/useTreeDnd.ts
index 093e3c60c..1aad971a3 100644
--- a/javascript/src/frontend/collab_forms/forms/project_collabnotes/hooks/useTreeDnd.ts
+++ b/javascript/src/frontend/collab_forms/forms/project_collabnotes/hooks/useTreeDnd.ts
@@ -103,17 +103,19 @@ export function useTreeDnd({ flatNodes, moveNote, onTreeMutated }: UseTreeDndPro
return;
}
- // Determine drop position based on cursor Y relative to the over item
+ // Determine drop position based on dragged item's Y relative to the over item.
+ // Use the translated rect of the active (dragged) element to get current position.
let dropPosition: DropPosition;
const overRect = over.rect;
- const pointerY = (event.activatorEvent as PointerEvent)?.clientY;
- const delta = event.delta?.y ?? 0;
- const cursorY = pointerY !== undefined ? pointerY + delta : undefined;
+ const activeTranslated = active.rect.current.translated;
+ const activeCenterY = activeTranslated
+ ? activeTranslated.top + activeTranslated.height / 2
+ : null;
if (overNode.nodeType === "folder") {
// Folders have 3 zones: top 25% = before, middle 50% = inside, bottom 25% = after
- if (cursorY !== undefined && overRect) {
- const relY = cursorY - overRect.top;
+ if (activeCenterY !== null && overRect) {
+ const relY = activeCenterY - overRect.top;
const height = overRect.height;
if (relY < height * 0.25) {
dropPosition = "before";
@@ -127,8 +129,8 @@ export function useTreeDnd({ flatNodes, moveNote, onTreeMutated }: UseTreeDndPro
}
} else {
// Notes have 2 zones: top 50% = before, bottom 50% = after
- if (cursorY !== undefined && overRect) {
- const relY = cursorY - overRect.top;
+ if (activeCenterY !== null && overRect) {
+ const relY = activeCenterY - overRect.top;
dropPosition = relY < overRect.height * 0.5 ? "before" : "after";
} else {
dropPosition = "after";
From a06f2a1aa262fc0c05bf6f6bed861c0f13dd6985 Mon Sep 17 00:00:00 2001
From: BlaiseOfGlory <5067183+BlaiseOfGlory@users.noreply.github.com>
Date: Wed, 1 Apr 2026 10:33:20 -0500
Subject: [PATCH 06/18] Fix timestamp DB defaults, thicker drag indicators,
debug logging
- Add RunSQL migration for NOW() defaults on timestamp columns
(Django 4.2 default= is ORM-only; Hasura/collab server need DB defaults)
- Add Hasura column presets for created_at/updated_at on insert
- Include Django auto-generated BigAutoField migration
- Simplify DnD drop position to use delta.y direction
- Make drop indicator lines 3px with glow, folders get outline
- Add console.debug for drag-over to diagnose indicator visibility
---
.../project_collabnotes/hooks/useTreeDnd.ts | 49 +++++++------------
.../forms/project_collabnotes/tree.css | 14 ++++--
2 files changed, 28 insertions(+), 35 deletions(-)
diff --git a/javascript/src/frontend/collab_forms/forms/project_collabnotes/hooks/useTreeDnd.ts b/javascript/src/frontend/collab_forms/forms/project_collabnotes/hooks/useTreeDnd.ts
index 1aad971a3..ecfadb716 100644
--- a/javascript/src/frontend/collab_forms/forms/project_collabnotes/hooks/useTreeDnd.ts
+++ b/javascript/src/frontend/collab_forms/forms/project_collabnotes/hooks/useTreeDnd.ts
@@ -1,4 +1,4 @@
-import { useState, useCallback } from "react";
+import { useState, useCallback, useRef } from "react";
import { DragEndEvent, DragOverEvent, DragStartEvent } from "@dnd-kit/core";
import { FlatNote, DropPosition, DragState } from "../types";
@@ -14,6 +14,7 @@ export function useTreeDnd({ flatNodes, moveNote, onTreeMutated }: UseTreeDndPro
overId: null,
dropPosition: null,
});
+ const lastOverId = useRef
(null);
const isDescendant = useCallback(
(nodeId: number, ancestorId: number): boolean => {
@@ -78,6 +79,7 @@ export function useTreeDnd({ flatNodes, moveNote, onTreeMutated }: UseTreeDndPro
);
const handleDragStart = useCallback((event: DragStartEvent) => {
+ lastOverId.current = null;
setDragState({ activeId: event.active.id as number, overId: null, dropPosition: null });
}, []);
@@ -86,6 +88,7 @@ export function useTreeDnd({ flatNodes, moveNote, onTreeMutated }: UseTreeDndPro
const { active, over } = event;
if (!over) {
setDragState((prev) => ({ ...prev, overId: null, dropPosition: null }));
+ lastOverId.current = null;
return;
}
@@ -103,44 +106,24 @@ export function useTreeDnd({ flatNodes, moveNote, onTreeMutated }: UseTreeDndPro
return;
}
- // Determine drop position based on dragged item's Y relative to the over item.
- // Use the translated rect of the active (dragged) element to get current position.
+ // Use the drag delta to determine direction of movement.
+ // Positive delta.y = moving down, negative = moving up.
+ // Combined with which item we're over, this determines before/after.
let dropPosition: DropPosition;
- const overRect = over.rect;
- const activeTranslated = active.rect.current.translated;
- const activeCenterY = activeTranslated
- ? activeTranslated.top + activeTranslated.height / 2
- : null;
+ const movingDown = event.delta.y > 0;
if (overNode.nodeType === "folder") {
- // Folders have 3 zones: top 25% = before, middle 50% = inside, bottom 25% = after
- if (activeCenterY !== null && overRect) {
- const relY = activeCenterY - overRect.top;
- const height = overRect.height;
- if (relY < height * 0.25) {
- dropPosition = "before";
- } else if (relY > height * 0.75) {
- dropPosition = "after";
- } else {
- dropPosition = "inside";
- }
- } else {
- dropPosition = "inside";
- }
+ // For folders: default to "inside", but if we just arrived
+ // from a different item use direction to pick before/after
+ dropPosition = "inside";
} else {
- // Notes have 2 zones: top 50% = before, bottom 50% = after
- if (activeCenterY !== null && overRect) {
- const relY = activeCenterY - overRect.top;
- dropPosition = relY < overRect.height * 0.5 ? "before" : "after";
- } else {
- dropPosition = "after";
- }
+ // For notes: top half = before, bottom half = after
+ dropPosition = movingDown ? "after" : "before";
}
if (!isValidDrop(activeId, overId, dropPosition)) {
- // Fallback: try "after" if "inside" was invalid
if (dropPosition === "inside") {
- dropPosition = "after";
+ dropPosition = movingDown ? "after" : "before";
if (!isValidDrop(activeId, overId, dropPosition)) {
setDragState((prev) => ({ ...prev, overId: null, dropPosition: null }));
return;
@@ -151,6 +134,8 @@ export function useTreeDnd({ flatNodes, moveNote, onTreeMutated }: UseTreeDndPro
}
}
+ lastOverId.current = overId;
+ console.debug("DnD dragOver:", { overId, dropPosition, deltaY: event.delta.y });
setDragState((prev) => ({ ...prev, overId, dropPosition }));
},
[flatNodes, isValidDrop]
@@ -162,6 +147,7 @@ export function useTreeDnd({ flatNodes, moveNote, onTreeMutated }: UseTreeDndPro
const { overId, dropPosition } = dragState;
setDragState({ activeId: null, overId: null, dropPosition: null });
+ lastOverId.current = null;
if (!over || overId === null || dropPosition === null) return;
@@ -184,6 +170,7 @@ export function useTreeDnd({ flatNodes, moveNote, onTreeMutated }: UseTreeDndPro
const handleDragCancel = useCallback(() => {
setDragState({ activeId: null, overId: null, dropPosition: null });
+ lastOverId.current = null;
}, []);
return { dragState, handleDragStart, handleDragOver, handleDragEnd, handleDragCancel };
diff --git a/javascript/src/frontend/collab_forms/forms/project_collabnotes/tree.css b/javascript/src/frontend/collab_forms/forms/project_collabnotes/tree.css
index 55166faca..54e6e6416 100644
--- a/javascript/src/frontend/collab_forms/forms/project_collabnotes/tree.css
+++ b/javascript/src/frontend/collab_forms/forms/project_collabnotes/tree.css
@@ -9,28 +9,34 @@
.tree-item-drop-before::before {
content: "";
position: absolute;
- top: 0;
+ top: -1px;
left: 0;
right: 0;
- height: 2px;
+ height: 3px;
background-color: #0d6efd;
+ border-radius: 2px;
z-index: 10;
+ box-shadow: 0 0 0 1px rgba(13, 110, 253, 0.3);
}
.tree-item-drop-after::after {
content: "";
position: absolute;
- bottom: 0;
+ bottom: -1px;
left: 0;
right: 0;
- height: 2px;
+ height: 3px;
background-color: #0d6efd;
+ border-radius: 2px;
z-index: 10;
+ box-shadow: 0 0 0 1px rgba(13, 110, 253, 0.3);
}
.tree-item-drop-inside > .tree-item {
background-color: #cfe2ff;
border-radius: 4px;
+ outline: 2px solid #0d6efd;
+ outline-offset: -2px;
}
.tree-drag-overlay {
From 378a60920996b6a9393ee03a6e202b2eac95c581 Mon Sep 17 00:00:00 2001
From: BlaiseOfGlory <5067183+BlaiseOfGlory@users.noreply.github.com>
Date: Wed, 1 Apr 2026 11:07:02 -0500
Subject: [PATCH 07/18] Fix drag-over not firing in nested tree structure
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Switch from closestCenter to pointerWithin collision detection —
closestCenter fails with nested DOM structures because sortable
items are inside each other. Also bind handleDragOver to onDragMove
which fires on every pointer movement, not just when the over
target changes.
---
.../collab_forms/forms/project_collabnotes/NoteTreeView.tsx | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/javascript/src/frontend/collab_forms/forms/project_collabnotes/NoteTreeView.tsx b/javascript/src/frontend/collab_forms/forms/project_collabnotes/NoteTreeView.tsx
index 670578f93..35b35e2a3 100644
--- a/javascript/src/frontend/collab_forms/forms/project_collabnotes/NoteTreeView.tsx
+++ b/javascript/src/frontend/collab_forms/forms/project_collabnotes/NoteTreeView.tsx
@@ -2,7 +2,7 @@ import { useState, useMemo, useCallback } from "react";
import {
DndContext,
DragOverlay,
- closestCenter,
+ pointerWithin,
KeyboardSensor,
PointerSensor,
useSensor,
@@ -233,8 +233,9 @@ export default function NoteTreeView({
Date: Wed, 1 Apr 2026 12:06:57 -0500
Subject: [PATCH 08/18] Load collab notes CSS in project detail template
The Vite build extracts CSS into separate files but the template
only loaded the JS bundle. Add stylesheet links for both the
component CSS and the shared collab CSS.
---
ghostwriter/rolodex/templates/rolodex/project_detail.html | 2 ++
.../collab_forms/forms/project_collabnotes/hooks/useTreeDnd.ts | 3 ++-
2 files changed, 4 insertions(+), 1 deletion(-)
diff --git a/ghostwriter/rolodex/templates/rolodex/project_detail.html b/ghostwriter/rolodex/templates/rolodex/project_detail.html
index 91a3798cc..c3c5d8b9f 100644
--- a/ghostwriter/rolodex/templates/rolodex/project_detail.html
+++ b/ghostwriter/rolodex/templates/rolodex/project_detail.html
@@ -2152,5 +2152,7 @@ Related Log Entri
{% endfor %}
{% include "collab_editing/attrs_snippet.html" %}
+
+
{% endblock %}
diff --git a/javascript/src/frontend/collab_forms/forms/project_collabnotes/hooks/useTreeDnd.ts b/javascript/src/frontend/collab_forms/forms/project_collabnotes/hooks/useTreeDnd.ts
index ecfadb716..2b24b71ea 100644
--- a/javascript/src/frontend/collab_forms/forms/project_collabnotes/hooks/useTreeDnd.ts
+++ b/javascript/src/frontend/collab_forms/forms/project_collabnotes/hooks/useTreeDnd.ts
@@ -86,6 +86,7 @@ export function useTreeDnd({ flatNodes, moveNote, onTreeMutated }: UseTreeDndPro
const handleDragOver = useCallback(
(event: DragOverEvent) => {
const { active, over } = event;
+ console.warn("DnD handler called, over:", over?.id ?? "null", "delta:", event.delta);
if (!over) {
setDragState((prev) => ({ ...prev, overId: null, dropPosition: null }));
lastOverId.current = null;
@@ -135,7 +136,7 @@ export function useTreeDnd({ flatNodes, moveNote, onTreeMutated }: UseTreeDndPro
}
lastOverId.current = overId;
- console.debug("DnD dragOver:", { overId, dropPosition, deltaY: event.delta.y });
+ console.warn("DnD dragOver:", { overId, dropPosition, deltaY: event.delta.y });
setDragState((prev) => ({ ...prev, overId, dropPosition }));
},
[flatNodes, isValidDrop]
From 103d8979417f2acfe0d0429a862ad85c40ead29b Mon Sep 17 00:00:00 2001
From: BlaiseOfGlory <5067183+BlaiseOfGlory@users.noreply.github.com>
Date: Wed, 1 Apr 2026 12:14:32 -0500
Subject: [PATCH 09/18] Fix drag-drop positioning and add inline drop
indicators
- Update Yjs tree with both parentId AND position on drop
(was only updating parentId, causing items to land in the
right folder but at their old position)
- Add inline border styles for drop indicators as primary
approach (CSS pseudo-elements weren't visible)
---
.../forms/project_collabnotes/NoteTreeView.tsx | 10 +++++-----
.../forms/project_collabnotes/TreeItem.tsx | 15 ++++++++++++++-
.../forms/project_collabnotes/hooks/useTreeDnd.ts | 2 +-
3 files changed, 20 insertions(+), 7 deletions(-)
diff --git a/javascript/src/frontend/collab_forms/forms/project_collabnotes/NoteTreeView.tsx b/javascript/src/frontend/collab_forms/forms/project_collabnotes/NoteTreeView.tsx
index 35b35e2a3..7a430d7a8 100644
--- a/javascript/src/frontend/collab_forms/forms/project_collabnotes/NoteTreeView.tsx
+++ b/javascript/src/frontend/collab_forms/forms/project_collabnotes/NoteTreeView.tsx
@@ -71,9 +71,10 @@ export default function NoteTreeView({
handleDragOver,
handleDragEnd: rawDragEnd,
handleDragCancel,
+ calculateNewPosition,
} = useTreeDnd({ flatNodes, moveNote, onTreeMutated });
- // Wrap handleDragEnd to also update Yjs tree
+ // Wrap handleDragEnd to also update Yjs tree with both parent and position
const handleDragEnd = useCallback(
async (event: any) => {
const { active, over } = event;
@@ -84,15 +85,14 @@ export default function NoteTreeView({
const overNode = flatNodes.find((n) => n.id === overId);
if (overNode) {
const newParentId = dropPosition === "inside" ? overId : overNode.parentId;
- // Update the Yjs node with new parent/position
- // (the actual DB mutation happens in rawDragEnd)
- updateNode(activeId, { parentId: newParentId });
+ const newPosition = calculateNewPosition(overId, dropPosition, newParentId);
+ updateNode(activeId, { parentId: newParentId, position: newPosition });
}
}
await rawDragEnd(event);
},
- [dragState, flatNodes, rawDragEnd, updateNode]
+ [dragState, flatNodes, rawDragEnd, updateNode, calculateNewPosition]
);
const allItemIds = useMemo(() => flatNodes.map((n) => n.id), [flatNodes]);
diff --git a/javascript/src/frontend/collab_forms/forms/project_collabnotes/TreeItem.tsx b/javascript/src/frontend/collab_forms/forms/project_collabnotes/TreeItem.tsx
index f9376f553..6cb90c38e 100644
--- a/javascript/src/frontend/collab_forms/forms/project_collabnotes/TreeItem.tsx
+++ b/javascript/src/frontend/collab_forms/forms/project_collabnotes/TreeItem.tsx
@@ -63,6 +63,19 @@ export default function TreeItem({
const dropClass = dropPosition ? `tree-item-drop-${dropPosition}` : "";
+ // Inline drop indicator styles as fallback (works regardless of CSS loading)
+ const dropIndicatorStyle: React.CSSProperties = {};
+ if (dropPosition === "before") {
+ dropIndicatorStyle.borderTop = "3px solid #0d6efd";
+ } else if (dropPosition === "after") {
+ dropIndicatorStyle.borderBottom = "3px solid #0d6efd";
+ } else if (dropPosition === "inside") {
+ dropIndicatorStyle.outline = "2px solid #0d6efd";
+ dropIndicatorStyle.outlineOffset = "-2px";
+ dropIndicatorStyle.borderRadius = "4px";
+ dropIndicatorStyle.backgroundColor = "rgba(13, 110, 253, 0.1)";
+ }
+
const actionButtonClass = isSelected
? "btn btn-sm p-0 me-1 text-white"
: "btn btn-link btn-sm p-0 me-1";
@@ -72,7 +85,7 @@ export default function TreeItem({
: "btn btn-link btn-sm p-0 text-danger";
return (
-
+
Date: Wed, 1 Apr 2026 12:59:56 -0500
Subject: [PATCH 10/18] Sort tree nodes by position in buildTree
buildTree was pushing children in Y.Array insertion order, ignoring
the position field. After updateNode changed a node's position value,
the array order didn't change so items appeared in the wrong place.
Now sorts roots and children by position then title at every level.
---
.../forms/project_collabnotes/hooks/useTreeSync.ts | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/javascript/src/frontend/collab_forms/forms/project_collabnotes/hooks/useTreeSync.ts b/javascript/src/frontend/collab_forms/forms/project_collabnotes/hooks/useTreeSync.ts
index c72cc1120..1ccb0a3d8 100644
--- a/javascript/src/frontend/collab_forms/forms/project_collabnotes/hooks/useTreeSync.ts
+++ b/javascript/src/frontend/collab_forms/forms/project_collabnotes/hooks/useTreeSync.ts
@@ -182,5 +182,15 @@ function buildTree(flatNodes: FlatNote[]): NoteTreeNode[] {
}
}
+ // Sort by position then title at every level
+ const sortNodes = (a: NoteTreeNode, b: NoteTreeNode) =>
+ a.position !== b.position ? a.position - b.position : a.title.localeCompare(b.title);
+ roots.sort(sortNodes);
+ for (const node of nodeMap.values()) {
+ if (node.children.length > 1) {
+ node.children.sort(sortNodes);
+ }
+ }
+
return roots;
}
From 0e131250b3be243e3494f1b3bb22dcbaf66dae3e Mon Sep 17 00:00:00 2001
From: BlaiseOfGlory <5067183+BlaiseOfGlory@users.noreply.github.com>
Date: Wed, 1 Apr 2026 13:14:45 -0500
Subject: [PATCH 11/18] Fix loading spinner text spinning with the indicator
---
.../forms/project_collabnotes/NoteTreeView.tsx | 8 +++-----
1 file changed, 3 insertions(+), 5 deletions(-)
diff --git a/javascript/src/frontend/collab_forms/forms/project_collabnotes/NoteTreeView.tsx b/javascript/src/frontend/collab_forms/forms/project_collabnotes/NoteTreeView.tsx
index 7a430d7a8..7caecf6eb 100644
--- a/javascript/src/frontend/collab_forms/forms/project_collabnotes/NoteTreeView.tsx
+++ b/javascript/src/frontend/collab_forms/forms/project_collabnotes/NoteTreeView.tsx
@@ -194,11 +194,9 @@ export default function NoteTreeView({
if (loading) {
return (
-
-
- Loading...
-
-
Loading notes...
+
);
}
From 4cc3a86e502e4202b5d0328f1e911cf48265aaa8 Mon Sep 17 00:00:00 2001
From: BlaiseOfGlory <5067183+BlaiseOfGlory@users.noreply.github.com>
Date: Wed, 1 Apr 2026 13:34:54 -0500
Subject: [PATCH 12/18] Serve note images through authenticated Django view
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Ghostwriter doesn't expose /media/ directly — all file serving goes
through Django views with permission checks. Add serve_note_field_image
view that checks user_can_view before returning the file. Update upload
responses and collab server handler to use the new URL pattern instead
of raw /media/ paths.
---
ghostwriter/rolodex/urls.py | 5 +++++
ghostwriter/rolodex/views.py | 20 +++++++++++++++++--
.../handlers/project_collab_note.ts | 2 +-
3 files changed, 24 insertions(+), 3 deletions(-)
diff --git a/ghostwriter/rolodex/urls.py b/ghostwriter/rolodex/urls.py
index 648f6703e..e271cfc50 100644
--- a/ghostwriter/rolodex/urls.py
+++ b/ghostwriter/rolodex/urls.py
@@ -168,6 +168,11 @@
views.BloodhoundApiFetchView.as_view(),
name="ajax_bloodhound_fetch",
),
+ path(
+ "ajax/note/field/
/image/serve",
+ views.serve_note_field_image,
+ name="ajax_serve_note_image",
+ ),
path(
"ajax/note//field/image",
views.ajax_upload_note_field_image,
diff --git a/ghostwriter/rolodex/views.py b/ghostwriter/rolodex/views.py
index 5dbca182c..02d42a9e4 100644
--- a/ghostwriter/rolodex/views.py
+++ b/ghostwriter/rolodex/views.py
@@ -2296,6 +2296,20 @@ def get_context_data(self, **kwargs):
return ctx
+@login_required
+def serve_note_field_image(request, pk):
+ """Serve an image from a :model:`rolodex.ProjectCollabNoteField` with auth check."""
+ field = get_object_or_404(ProjectCollabNoteField, pk=pk)
+ if not field.note.user_can_view(request.user):
+ return ForbiddenJsonResponse()
+ if not field.image:
+ raise Http404
+ try:
+ return FileResponse(field.image.open("rb"), content_type="image/png")
+ except FileNotFoundError:
+ raise Http404
+
+
class NoteImageUploadForm(forms.Form):
"""Django form for validating image uploads to collab note fields."""
@@ -2365,13 +2379,14 @@ def _update_existing_image_field(request, note, field_pk, image_file):
)
field.image = image_file
field.save()
+ image_url = reverse("rolodex:ajax_serve_note_image", kwargs={"pk": field.pk})
logger.info(
"User %s uploaded image to existing field %s for note %s",
request.user, field.id, note.id,
)
return JsonResponse({
"result": "success",
- "imageUrl": request.build_absolute_uri(field.image.url),
+ "imageUrl": image_url,
})
@@ -2390,6 +2405,7 @@ def _create_new_image_field(request, note, image_file):
image=image_file,
position=max_position + 1,
)
+ image_url = reverse("rolodex:ajax_serve_note_image", kwargs={"pk": field.pk})
logger.info(
"User %s uploaded image field %s for note %s",
request.user, field.id, note.id,
@@ -2397,7 +2413,7 @@ def _create_new_image_field(request, note, image_file):
return JsonResponse({
"result": "success",
"id": field.id,
- "imageUrl": request.build_absolute_uri(field.image.url),
+ "imageUrl": image_url,
"position": field.position,
})
diff --git a/javascript/src/collab_server/handlers/project_collab_note.ts b/javascript/src/collab_server/handlers/project_collab_note.ts
index 9e2ccf199..2b2b7a486 100644
--- a/javascript/src/collab_server/handlers/project_collab_note.ts
+++ b/javascript/src/collab_server/handlers/project_collab_note.ts
@@ -68,7 +68,7 @@ const ProjectCollabNoteItemHandler: ModelHandler = {
fieldDataArr = [legacyField];
} else {
fieldDataArr = obj.fields.map((field: any) => {
- const imageUrl = field.image ? `/media/${field.image}` : null;
+ const imageUrl = field.image ? `/rolodex/ajax/note/field/${field.id}/image/serve` : null;
const fieldData: FieldData = {
id: field.id.toString(),
fieldType: field.fieldType,
From 2e678c31321a31b18ab086d1927d96b2bc3b5118 Mon Sep 17 00:00:00 2001
From: BlaiseOfGlory <5067183+BlaiseOfGlory@users.noreply.github.com>
Date: Wed, 1 Apr 2026 13:47:29 -0500
Subject: [PATCH 13/18] Expand collab notes container to fill viewport height
---
.../frontend/collab_forms/forms/project_collabnotes/index.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/javascript/src/frontend/collab_forms/forms/project_collabnotes/index.tsx b/javascript/src/frontend/collab_forms/forms/project_collabnotes/index.tsx
index 98776ed14..2592e6e69 100644
--- a/javascript/src/frontend/collab_forms/forms/project_collabnotes/index.tsx
+++ b/javascript/src/frontend/collab_forms/forms/project_collabnotes/index.tsx
@@ -15,7 +15,7 @@ function ProjectCollabNotesContainer() {
return (
Date: Wed, 1 Apr 2026 14:40:33 -0500
Subject: [PATCH 14/18] Add drop zones at top and bottom of note tree
Items couldn't be dragged to the very first or last position because
there was no sortable target above the first item or below the last.
Add sentinel droppable zones that highlight on hover and handle
placement at position -1000/+1000 relative to the first/last root
node.
---
.../project_collabnotes/NoteTreeView.tsx | 39 ++++++++++++++---
.../project_collabnotes/hooks/useTreeDnd.ts | 42 +++++++++++++------
2 files changed, 64 insertions(+), 17 deletions(-)
diff --git a/javascript/src/frontend/collab_forms/forms/project_collabnotes/NoteTreeView.tsx b/javascript/src/frontend/collab_forms/forms/project_collabnotes/NoteTreeView.tsx
index 7caecf6eb..d02885ed4 100644
--- a/javascript/src/frontend/collab_forms/forms/project_collabnotes/NoteTreeView.tsx
+++ b/javascript/src/frontend/collab_forms/forms/project_collabnotes/NoteTreeView.tsx
@@ -7,6 +7,7 @@ import {
PointerSensor,
useSensor,
useSensors,
+ useDroppable,
} from "@dnd-kit/core";
import {
SortableContext,
@@ -82,11 +83,22 @@ export default function NoteTreeView({
if (over && overId !== null && dropPosition !== null) {
const activeId = active.id as number;
- const overNode = flatNodes.find((n) => n.id === overId);
- if (overNode) {
- const newParentId = dropPosition === "inside" ? overId : overNode.parentId;
- const newPosition = calculateNewPosition(overId, dropPosition, newParentId);
- updateNode(activeId, { parentId: newParentId, position: newPosition });
+
+ if (overId === "tree-top" || overId === "tree-bottom") {
+ const rootNodes = flatNodes
+ .filter((n) => n.parentId === null)
+ .sort((a, b) => a.position - b.position);
+ const newPosition = overId === "tree-top"
+ ? (rootNodes.length > 0 ? rootNodes[0].position - 1000 : 0)
+ : (rootNodes.length > 0 ? rootNodes[rootNodes.length - 1].position + 1000 : 0);
+ updateNode(activeId, { parentId: null, position: newPosition });
+ } else {
+ const overNode = flatNodes.find((n) => n.id === overId);
+ if (overNode) {
+ const newParentId = dropPosition === "inside" ? (overId as number) : overNode.parentId;
+ const newPosition = calculateNewPosition(overId as number, dropPosition, newParentId);
+ updateNode(activeId, { parentId: newParentId, position: newPosition });
+ }
}
}
@@ -243,6 +255,7 @@ export default function NoteTreeView({
strategy={verticalListSortingStrategy}
>
+
{tree.map((item) => (
))}
+
{tree.length === 0 && (
No notes yet. Create one using the buttons above.
@@ -298,3 +312,18 @@ export default function NoteTreeView({
);
}
+
+function TreeDropSentinel({ id }: { id: string }) {
+ const { setNodeRef, isOver } = useDroppable({ id });
+ return (
+
+ );
+}
diff --git a/javascript/src/frontend/collab_forms/forms/project_collabnotes/hooks/useTreeDnd.ts b/javascript/src/frontend/collab_forms/forms/project_collabnotes/hooks/useTreeDnd.ts
index 86f93bfb9..f49f625c2 100644
--- a/javascript/src/frontend/collab_forms/forms/project_collabnotes/hooks/useTreeDnd.ts
+++ b/javascript/src/frontend/collab_forms/forms/project_collabnotes/hooks/useTreeDnd.ts
@@ -93,8 +93,17 @@ export function useTreeDnd({ flatNodes, moveNote, onTreeMutated }: UseTreeDndPro
return;
}
- const overId = over.id as number;
const activeId = active.id as number;
+ const overIdRaw = over.id;
+
+ // Handle sentinel drop zones at top/bottom of tree
+ if (overIdRaw === "tree-top" || overIdRaw === "tree-bottom") {
+ const dropPosition: DropPosition = overIdRaw === "tree-top" ? "before" : "after";
+ setDragState((prev) => ({ ...prev, overId: overIdRaw as any, dropPosition }));
+ return;
+ }
+
+ const overId = overIdRaw as number;
if (activeId === overId) {
setDragState((prev) => ({ ...prev, overId: null, dropPosition: null }));
@@ -107,18 +116,12 @@ export function useTreeDnd({ flatNodes, moveNote, onTreeMutated }: UseTreeDndPro
return;
}
- // Use the drag delta to determine direction of movement.
- // Positive delta.y = moving down, negative = moving up.
- // Combined with which item we're over, this determines before/after.
let dropPosition: DropPosition;
const movingDown = event.delta.y > 0;
if (overNode.nodeType === "folder") {
- // For folders: default to "inside", but if we just arrived
- // from a different item use direction to pick before/after
dropPosition = "inside";
} else {
- // For notes: top half = before, bottom half = after
dropPosition = movingDown ? "after" : "before";
}
@@ -136,7 +139,6 @@ export function useTreeDnd({ flatNodes, moveNote, onTreeMutated }: UseTreeDndPro
}
lastOverId.current = overId;
- console.warn("DnD dragOver:", { overId, dropPosition, deltaY: event.delta.y });
setDragState((prev) => ({ ...prev, overId, dropPosition }));
},
[flatNodes, isValidDrop]
@@ -153,11 +155,27 @@ export function useTreeDnd({ flatNodes, moveNote, onTreeMutated }: UseTreeDndPro
if (!over || overId === null || dropPosition === null) return;
const activeId = active.id as number;
- const overNode = flatNodes.find((n) => n.id === overId);
- if (!overNode) return;
- const newParentId = dropPosition === "inside" ? overId : overNode.parentId;
- const newPosition = calculateNewPosition(overId, dropPosition, newParentId);
+ // Handle sentinel drop zones
+ let newParentId: number | null;
+ let newPosition: number;
+
+ if (overId === "tree-top" || overId === "tree-bottom") {
+ const rootNodes = flatNodes
+ .filter((n) => n.parentId === null)
+ .sort((a, b) => a.position - b.position);
+ newParentId = null;
+ if (overId === "tree-top") {
+ newPosition = rootNodes.length > 0 ? rootNodes[0].position - 1000 : 0;
+ } else {
+ newPosition = rootNodes.length > 0 ? rootNodes[rootNodes.length - 1].position + 1000 : 0;
+ }
+ } else {
+ const overNode = flatNodes.find((n) => n.id === overId);
+ if (!overNode) return;
+ newParentId = dropPosition === "inside" ? (overId as number) : overNode.parentId;
+ newPosition = calculateNewPosition(overId as number, dropPosition, newParentId);
+ }
try {
await moveNote(activeId, newParentId, newPosition);
From 7d16dfaf6a56d9bd73d055caff31bf116721003d Mon Sep 17 00:00:00 2001
From: BlaiseOfGlory <5067183+BlaiseOfGlory@users.noreply.github.com>
Date: Wed, 1 Apr 2026 14:51:57 -0500
Subject: [PATCH 15/18] Fix sentinel drop zones and remove debug logging
Move sentinel droppables outside SortableContext so they don't
conflict with sortable collision detection. Increase sentinel
height to 24px for easier targeting. Remove console.warn debug
messages from drag handler.
---
.../project_collabnotes/NoteTreeView.tsx | 62 ++++++++++---------
.../project_collabnotes/hooks/useTreeDnd.ts | 2 +-
2 files changed, 33 insertions(+), 31 deletions(-)
diff --git a/javascript/src/frontend/collab_forms/forms/project_collabnotes/NoteTreeView.tsx b/javascript/src/frontend/collab_forms/forms/project_collabnotes/NoteTreeView.tsx
index d02885ed4..6d88a6de0 100644
--- a/javascript/src/frontend/collab_forms/forms/project_collabnotes/NoteTreeView.tsx
+++ b/javascript/src/frontend/collab_forms/forms/project_collabnotes/NoteTreeView.tsx
@@ -250,33 +250,35 @@ export default function NoteTreeView({
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
-
-
-
- {tree.map((item) => (
-
- ))}
-
- {tree.length === 0 && (
-
- No notes yet. Create one using the buttons above.
-
- )}
-
-
+
+
+
+
+ {tree.map((item) => (
+
+ ))}
+ {tree.length === 0 && (
+
+ No notes yet. Create one using the buttons above.
+
+ )}
+
+
+
+
{activeItem && (
@@ -319,9 +321,9 @@ function TreeDropSentinel({ id }: { id: string }) {
diff --git a/javascript/src/frontend/collab_forms/forms/project_collabnotes/hooks/useTreeDnd.ts b/javascript/src/frontend/collab_forms/forms/project_collabnotes/hooks/useTreeDnd.ts
index f49f625c2..47b4512eb 100644
--- a/javascript/src/frontend/collab_forms/forms/project_collabnotes/hooks/useTreeDnd.ts
+++ b/javascript/src/frontend/collab_forms/forms/project_collabnotes/hooks/useTreeDnd.ts
@@ -86,7 +86,7 @@ export function useTreeDnd({ flatNodes, moveNote, onTreeMutated }: UseTreeDndPro
const handleDragOver = useCallback(
(event: DragOverEvent) => {
const { active, over } = event;
- console.warn("DnD handler called, over:", over?.id ?? "null", "delta:", event.delta);
+
if (!over) {
setDragState((prev) => ({ ...prev, overId: null, dropPosition: null }));
lastOverId.current = null;
From 2ec095af32fbdb202af2c6d51afd2a17b29fbdf3 Mon Sep 17 00:00:00 2001
From: BlaiseOfGlory <5067183+BlaiseOfGlory@users.noreply.github.com>
Date: Wed, 1 Apr 2026 14:57:22 -0500
Subject: [PATCH 16/18] Use pointer position for folder before/inside/after
zones
Folders now split into three drop zones based on pointer Y relative
to the item rect: top 25% = before, middle 50% = inside, bottom
25% = after. Notes split at 50% for before/after. This allows
dragging items between adjacent folders instead of always dropping
inside.
---
.../project_collabnotes/hooks/useTreeDnd.ts | 28 +++++++++++++++++--
1 file changed, 25 insertions(+), 3 deletions(-)
diff --git a/javascript/src/frontend/collab_forms/forms/project_collabnotes/hooks/useTreeDnd.ts b/javascript/src/frontend/collab_forms/forms/project_collabnotes/hooks/useTreeDnd.ts
index 47b4512eb..2c8f057d2 100644
--- a/javascript/src/frontend/collab_forms/forms/project_collabnotes/hooks/useTreeDnd.ts
+++ b/javascript/src/frontend/collab_forms/forms/project_collabnotes/hooks/useTreeDnd.ts
@@ -116,13 +116,35 @@ export function useTreeDnd({ flatNodes, moveNote, onTreeMutated }: UseTreeDndPro
return;
}
+ // Compute pointer Y from initial activation point + drag delta
let dropPosition: DropPosition;
- const movingDown = event.delta.y > 0;
+ const activatorY = (event.activatorEvent as PointerEvent)?.clientY;
+ const pointerY = activatorY != null ? activatorY + event.delta.y : null;
+ const overRect = over.rect;
if (overNode.nodeType === "folder") {
- dropPosition = "inside";
+ // Folders: top 25% = before, middle 50% = inside, bottom 25% = after
+ if (pointerY != null && overRect && overRect.height > 0) {
+ const relY = pointerY - overRect.top;
+ const ratio = relY / overRect.height;
+ if (ratio < 0.25) {
+ dropPosition = "before";
+ } else if (ratio > 0.75) {
+ dropPosition = "after";
+ } else {
+ dropPosition = "inside";
+ }
+ } else {
+ dropPosition = "inside";
+ }
} else {
- dropPosition = movingDown ? "after" : "before";
+ // Notes: top half = before, bottom half = after
+ if (pointerY != null && overRect && overRect.height > 0) {
+ const relY = pointerY - overRect.top;
+ dropPosition = relY < overRect.height * 0.5 ? "before" : "after";
+ } else {
+ dropPosition = event.delta.y > 0 ? "after" : "before";
+ }
}
if (!isValidDrop(activeId, overId, dropPosition)) {
From bc1daa0aa5ad116026f0f8f65fed9c6b77fbf9a3 Mon Sep 17 00:00:00 2001
From: BlaiseOfGlory <5067183+BlaiseOfGlory@users.noreply.github.com>
Date: Wed, 1 Apr 2026 16:24:04 -0500
Subject: [PATCH 17/18] Add package-lock.json and test data seed script
- Lock @dnd-kit dependency versions from npm install
- Add scripts/seed_test_data.py for populating dev environment
with test users, clients, projects, and collab notes
---
javascript/package-lock.json | 56 ++++++++
scripts/seed_test_data.py | 260 +++++++++++++++++++++++++++++++++++
2 files changed, 316 insertions(+)
create mode 100644 scripts/seed_test_data.py
diff --git a/javascript/package-lock.json b/javascript/package-lock.json
index 95bbfa65b..a8ab7a786 100644
--- a/javascript/package-lock.json
+++ b/javascript/package-lock.json
@@ -6,6 +6,9 @@
"": {
"dependencies": {
"@apollo/client": "^3.13.1",
+ "@dnd-kit/core": "^6.3.1",
+ "@dnd-kit/sortable": "^10.0.0",
+ "@dnd-kit/utilities": "^3.2.2",
"@fortawesome/fontawesome-svg-core": "^6.7.2",
"@fortawesome/free-regular-svg-icons": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.7.2",
@@ -412,6 +415,59 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@dnd-kit/accessibility": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
+ "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0"
+ }
+ },
+ "node_modules/@dnd-kit/core": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
+ "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@dnd-kit/accessibility": "^3.1.1",
+ "@dnd-kit/utilities": "^3.2.2",
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0",
+ "react-dom": ">=16.8.0"
+ }
+ },
+ "node_modules/@dnd-kit/sortable": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
+ "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
+ "license": "MIT",
+ "dependencies": {
+ "@dnd-kit/utilities": "^3.2.2",
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "@dnd-kit/core": "^6.3.0",
+ "react": ">=16.8.0"
+ }
+ },
+ "node_modules/@dnd-kit/utilities": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
+ "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0"
+ }
+ },
"node_modules/@envelop/core": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/@envelop/core/-/core-5.3.2.tgz",
diff --git a/scripts/seed_test_data.py b/scripts/seed_test_data.py
new file mode 100644
index 000000000..c89c214bb
--- /dev/null
+++ b/scripts/seed_test_data.py
@@ -0,0 +1,260 @@
+"""
+Seed script for populating Ghostwriter with test data.
+
+Run via: docker compose -f local.yml exec django python manage.py shell < scripts/seed_test_data.py
+"""
+
+from datetime import date, timedelta
+from django.contrib.auth import get_user_model
+from ghostwriter.rolodex.models import (
+ Client,
+ ClientContact,
+ ClientInvite,
+ Project,
+ ProjectAssignment,
+ ProjectCollabNote,
+ ProjectCollabNoteField,
+ ProjectInvite,
+ ProjectObjective,
+ ProjectScope,
+ ProjectTarget,
+)
+from ghostwriter.rolodex.models import ProjectType, ProjectRole
+from ghostwriter.reporting.models import Severity, FindingType
+
+User = get_user_model()
+
+# ---------------------------------------------------------------------------
+# Users
+# ---------------------------------------------------------------------------
+users = {}
+user_specs = [
+ ("alice", "alice@example.com", "Alice Rivera", "TestPass123!"),
+ ("bob", "bob@example.com", "Bob Chen", "TestPass123!"),
+ ("charlie", "charlie@example.com", "Charlie Okafor", "TestPass123!"),
+]
+for username, email, name, pw in user_specs:
+ u, created = User.objects.get_or_create(
+ username=username,
+ defaults=dict(email=email, name=name, role="user"),
+ )
+ if created:
+ u.set_password(pw)
+ u.save()
+ print(f" Created user: {username} / {pw}")
+ else:
+ print(f" User already exists: {username}")
+ users[username] = u
+
+mgr, created = User.objects.get_or_create(
+ username="manager",
+ defaults=dict(email="mgr@example.com", name="Morgan Taylor", role="manager"),
+)
+if created:
+ mgr.set_password("TestPass123!")
+ mgr.save()
+ print(" Created manager: manager / TestPass123!")
+else:
+ print(" Manager already exists: manager")
+users["manager"] = mgr
+
+# ---------------------------------------------------------------------------
+# Clients
+# ---------------------------------------------------------------------------
+pt_pentest = ProjectType.objects.get(project_type="Penetration Test")
+pt_webapp = ProjectType.objects.get(project_type="Web Application Assessment")
+pt_redteam = ProjectType.objects.get(project_type="Red Team")
+
+role_lead = ProjectRole.objects.get(project_role="Assessment Lead")
+role_operator = ProjectRole.objects.get(project_role="Operator")
+
+clients_data = [
+ {
+ "name": "Acme Corporation",
+ "short_name": "ACME",
+ "codename": "THUNDERBOLT",
+ "contacts": [
+ ("Jane Doe", "jane.doe@acme.example.com", "CISO", "555-0101"),
+ ("John Smith", "john.smith@acme.example.com", "IT Director", "555-0102"),
+ ],
+ "projects": [
+ {
+ "codename": "IRON FALCON",
+ "project_type": pt_pentest,
+ "lead": "alice",
+ "operators": ["bob"],
+ "start": date.today() - timedelta(days=14),
+ "end": date.today() + timedelta(days=16),
+ "scopes": ["10.10.0.0/16", "web.acme.example.com", "api.acme.example.com"],
+ "targets": ["Domain Controller (DC01)", "Exchange Server", "VPN Gateway"],
+ "notes_tree": [
+ {"title": "Reconnaissance", "type": "folder", "children": [
+ {"title": "External Recon", "type": "note", "content": "Ran subdomain enumeration against acme.example.com. Found 14 subdomains.
"},
+ {"title": "OSINT Findings", "type": "note", "content": "LinkedIn reveals 3 IT admins. Password policy doc leaked on Pastebin (expired).
"},
+ ]},
+ {"title": "Exploitation", "type": "folder", "children": [
+ {"title": "Initial Access", "type": "note", "content": "Phishing payload delivered via macro-enabled doc. User jane.doe clicked.
"},
+ {"title": "Lateral Movement", "type": "note", "content": "Used Pass-the-Hash from workstation WS-042 to reach DC01.
"},
+ ]},
+ {"title": "Meeting Notes", "type": "note", "content": "Kickoff call 2026-03-17. Client wants focus on AD attack paths.
"},
+ ],
+ },
+ ],
+ },
+ {
+ "name": "Globex Industries",
+ "short_name": "GLOBEX",
+ "codename": "NIGHTSHADE",
+ "contacts": [
+ ("Hank Scorpio", "hank@globex.example.com", "CEO", "555-0200"),
+ ],
+ "projects": [
+ {
+ "codename": "SHADOW NEXUS",
+ "project_type": pt_webapp,
+ "lead": "bob",
+ "operators": ["charlie"],
+ "start": date.today() - timedelta(days=7),
+ "end": date.today() + timedelta(days=23),
+ "scopes": ["https://portal.globex.example.com", "https://api.globex.example.com/v2"],
+ "targets": ["Customer Portal", "REST API v2", "Admin Panel"],
+ "notes_tree": [
+ {"title": "API Testing", "type": "folder", "children": [
+ {"title": "Authentication Bypass", "type": "note", "content": "JWT validation can be bypassed by setting alg=none. Critical finding.
"},
+ {"title": "IDOR on /users endpoint", "type": "note", "content": "Changing user_id in request allows access to other users' data.
"},
+ ]},
+ {"title": "Portal XSS", "type": "note", "content": "Stored XSS in profile bio field. Payload: <img src=x onerror=alert(1)>
"},
+ ],
+ },
+ ],
+ },
+ {
+ "name": "Initech",
+ "short_name": "INIT",
+ "codename": "REDSTAPLER",
+ "contacts": [
+ ("Bill Lumbergh", "bill@initech.example.com", "VP", "555-0300"),
+ ("Milton Waddams", "milton@initech.example.com", "Facilities", "555-0301"),
+ ],
+ "projects": [
+ {
+ "codename": "CRIMSON TIDE",
+ "project_type": pt_redteam,
+ "lead": "charlie",
+ "operators": ["alice", "bob"],
+ "start": date.today(),
+ "end": date.today() + timedelta(days=30),
+ "scopes": ["*.initech.example.com", "10.20.0.0/16", "Physical: HQ Building"],
+ "targets": ["CEO Laptop", "Financial Database", "Badge System"],
+ "notes_tree": [
+ {"title": "Planning", "type": "folder", "children": [
+ {"title": "Rules of Engagement", "type": "note", "content": "No DoS. No production DB modification. Physical access authorized M-F 8am-6pm only.
"},
+ {"title": "C2 Infrastructure", "type": "note", "content": "Cobalt Strike teamserver on AWS. Redirector chain: CloudFront → Nginx → TS.
"},
+ ]},
+ {"title": "Execution Log", "type": "folder", "children": []},
+ ],
+ },
+ ],
+ },
+]
+
+for cdata in clients_data:
+ client, created = Client.objects.get_or_create(
+ name=cdata["name"],
+ defaults=dict(short_name=cdata["short_name"], codename=cdata["codename"]),
+ )
+ action = "Created" if created else "Exists"
+ print(f"\n{action} client: {client.name}")
+
+ for cname, cemail, ctitle, cphone in cdata["contacts"]:
+ ClientContact.objects.get_or_create(
+ client=client,
+ email=cemail,
+ defaults=dict(name=cname, job_title=ctitle, phone=cphone),
+ )
+
+ # Invite all users to the client
+ for u in users.values():
+ ClientInvite.objects.get_or_create(client=client, user=u)
+
+ for pdata in cdata["projects"]:
+ project, created = Project.objects.get_or_create(
+ codename=pdata["codename"],
+ defaults=dict(
+ client=client,
+ project_type=pdata["project_type"],
+ start_date=pdata["start"],
+ end_date=pdata["end"],
+ complete=False,
+ ),
+ )
+ action = "Created" if created else "Exists"
+ print(f" {action} project: {project.codename}")
+
+ # Assignments
+ lead_user = users[pdata["lead"]]
+ ProjectAssignment.objects.get_or_create(
+ project=project, operator=lead_user,
+ defaults=dict(role=role_lead, start_date=pdata["start"], end_date=pdata["end"]),
+ )
+ for op_name in pdata["operators"]:
+ op_user = users[op_name]
+ ProjectAssignment.objects.get_or_create(
+ project=project, operator=op_user,
+ defaults=dict(role=role_operator, start_date=pdata["start"], end_date=pdata["end"]),
+ )
+
+ # Invite manager
+ ProjectInvite.objects.get_or_create(project=project, user=users["manager"])
+
+ # Scopes
+ for i, scope_name in enumerate(pdata["scopes"]):
+ ProjectScope.objects.get_or_create(
+ project=project, name=scope_name,
+ defaults=dict(scope=scope_name),
+ )
+
+ # Targets
+ for i, target_name in enumerate(pdata["targets"]):
+ ProjectTarget.objects.get_or_create(
+ project=project, hostname=target_name,
+ )
+
+ # Collab notes tree
+ def create_notes_tree(nodes, project, parent=None, pos_start=0):
+ for i, node in enumerate(nodes):
+ position = (pos_start + i) * 1000
+ note, _ = ProjectCollabNote.objects.get_or_create(
+ project=project,
+ title=node["title"],
+ parent=parent,
+ defaults=dict(
+ node_type=node["type"],
+ content=node.get("content", ""),
+ position=position,
+ ),
+ )
+ if node["type"] == "note" and node.get("content"):
+ ProjectCollabNoteField.objects.get_or_create(
+ note=note,
+ position=0,
+ defaults=dict(
+ field_type="rich_text",
+ content=node["content"],
+ ),
+ )
+ if "children" in node:
+ create_notes_tree(node["children"], project, parent=note)
+
+ if created:
+ create_notes_tree(pdata["notes_tree"], project)
+
+print("\n" + "=" * 60)
+print("SEED DATA COMPLETE")
+print("=" * 60)
+print(f"Users: {User.objects.count()} (login with TestPass123!)")
+print(f"Clients: {Client.objects.count()}")
+print(f"Projects: {Project.objects.count()}")
+print(f"Collab Notes: {ProjectCollabNote.objects.count()}")
+print(f"Collab Note Fields: {ProjectCollabNoteField.objects.count()}")
+print("=" * 60)
From 7c4d4c1333a4bfe270360bf460ded992f82a2e8b Mon Sep 17 00:00:00 2001
From: BlaiseOfGlory <5067183+BlaiseOfGlory@users.noreply.github.com>
Date: Wed, 1 Apr 2026 16:36:36 -0500
Subject: [PATCH 18/18] Address CodeFactor issues from PR #855
Python:
- Use 'raise Http404 from exc' for explicit exception chaining
- Move markdownify import to module level
- Rewrite seed script: remove unused imports, use dict literals,
extract functions to fix scope and redefinition warnings
TypeScript:
- Extract computeDropPosition() and sentinelPosition() from
handleDragOver/handleDragEnd to reduce cyclomatic complexity
- Extract TreeItemIcon, TreeItemChevron, TreeItemActions from
TreeItem to reduce component complexity
---
ghostwriter/rolodex/views.py | 6 +-
.../forms/project_collabnotes/TreeItem.tsx | 112 ++---
.../project_collabnotes/hooks/useTreeDnd.ts | 79 ++--
scripts/seed_test_data.py | 421 +++++++++---------
4 files changed, 313 insertions(+), 305 deletions(-)
diff --git a/ghostwriter/rolodex/views.py b/ghostwriter/rolodex/views.py
index 02d42a9e4..0336e673a 100644
--- a/ghostwriter/rolodex/views.py
+++ b/ghostwriter/rolodex/views.py
@@ -39,6 +39,7 @@
from django.db.models import Exists, OuterRef
# 3rd Party Libraries
+from markdownify import markdownify as md
from taggit.models import Tag
# Ghostwriter Libraries
@@ -2306,8 +2307,8 @@ def serve_note_field_image(request, pk):
raise Http404
try:
return FileResponse(field.image.open("rb"), content_type="image/png")
- except FileNotFoundError:
- raise Http404
+ except FileNotFoundError as exc:
+ raise Http404 from exc
class NoteImageUploadForm(forms.Form):
@@ -2431,7 +2432,6 @@ def _sanitize_filename(name):
@login_required
def export_collab_notes_zip(request, pk):
"""Export all collab notes for a project as a streaming ZIP response."""
- from markdownify import markdownify as md
project = get_object_or_404(Project, pk=pk)
diff --git a/javascript/src/frontend/collab_forms/forms/project_collabnotes/TreeItem.tsx b/javascript/src/frontend/collab_forms/forms/project_collabnotes/TreeItem.tsx
index 6cb90c38e..6bf1aec25 100644
--- a/javascript/src/frontend/collab_forms/forms/project_collabnotes/TreeItem.tsx
+++ b/javascript/src/frontend/collab_forms/forms/project_collabnotes/TreeItem.tsx
@@ -16,6 +16,53 @@ interface TreeItemProps {
renderChildren?: (children: NoteTreeNode[], depth: number) => React.ReactNode;
}
+function TreeItemIcon({ isFolder, expanded }: { isFolder: boolean; expanded: boolean }) {
+ if (isFolder) {
+ return ;
+ }
+ return ;
+}
+
+function TreeItemChevron({ isFolder, hasChildren, expanded }: { isFolder: boolean; hasChildren: boolean; expanded: boolean }) {
+ if (!isFolder || !hasChildren) return null;
+ return expanded
+ ?
+ : ;
+}
+
+interface ActionButtonsProps {
+ isFolder: boolean;
+ buttonClass: string;
+ deleteClass: string;
+ onAddNote: (e: React.MouseEvent) => void;
+ onAddFolder: (e: React.MouseEvent) => void;
+ onRename: (e: React.MouseEvent) => void;
+ onDelete: (e: React.MouseEvent) => void;
+}
+
+function TreeItemActions({ isFolder, buttonClass, deleteClass, onAddNote, onAddFolder, onRename, onDelete }: ActionButtonsProps) {
+ return (
+
+ {isFolder && (
+ <>
+
+
+
+
+
+
+ >
+ )}
+
+
+
+
+
+
+
+ );
+}
+
export default function TreeItem({
item,
depth,
@@ -36,14 +83,10 @@ export default function TreeItem({
const isFolder = item.nodeType === "folder";
const isSelected = selectedId === item.id;
- const hasChildren = item.children.length > 0;
const handleClick = () => {
- if (isFolder) {
- setExpanded(!expanded);
- } else {
- onSelect(item.id);
- }
+ if (isFolder) setExpanded(!expanded);
+ else onSelect(item.id);
};
const handleRenameSubmit = () => {
@@ -63,7 +106,6 @@ export default function TreeItem({
const dropClass = dropPosition ? `tree-item-drop-${dropPosition}` : "";
- // Inline drop indicator styles as fallback (works regardless of CSS loading)
const dropIndicatorStyle: React.CSSProperties = {};
if (dropPosition === "before") {
dropIndicatorStyle.borderTop = "3px solid #0d6efd";
@@ -79,7 +121,6 @@ export default function TreeItem({
const actionButtonClass = isSelected
? "btn btn-sm p-0 me-1 text-white"
: "btn btn-link btn-sm p-0 me-1";
-
const deleteButtonClass = isSelected
? "btn btn-sm p-0 text-white"
: "btn btn-link btn-sm p-0 text-danger";
@@ -101,19 +142,11 @@ export default function TreeItem({
{...dragHandleProps}
>
- {isFolder && hasChildren && (
- expanded
- ?
- :
- )}
+ 0} expanded={expanded} />
- {isFolder ? (
-
- ) : (
-
- )}
+
{isRenaming ? (
@@ -135,40 +168,15 @@ export default function TreeItem({
)}
{!isRenaming && (
-
- {isFolder && (
- <>
- { e.stopPropagation(); onCreateChild(item.id, "note"); setExpanded(true); }}
- title="Add note"
- >
-
-
- { e.stopPropagation(); onCreateChild(item.id, "folder"); setExpanded(true); }}
- title="Add subfolder"
- >
-
-
- >
- )}
- { e.stopPropagation(); setRenameValue(item.title); setIsRenaming(true); }}
- title="Rename"
- >
-
-
- { e.stopPropagation(); onRequestDelete(item); }}
- title="Delete"
- >
-
-
-
+ { e.stopPropagation(); onCreateChild(item.id, "note"); setExpanded(true); }}
+ onAddFolder={(e) => { e.stopPropagation(); onCreateChild(item.id, "folder"); setExpanded(true); }}
+ onRename={(e) => { e.stopPropagation(); setRenameValue(item.title); setIsRenaming(true); }}
+ onDelete={(e) => { e.stopPropagation(); onRequestDelete(item); }}
+ />
)}
diff --git a/javascript/src/frontend/collab_forms/forms/project_collabnotes/hooks/useTreeDnd.ts b/javascript/src/frontend/collab_forms/forms/project_collabnotes/hooks/useTreeDnd.ts
index 2c8f057d2..45654afe2 100644
--- a/javascript/src/frontend/collab_forms/forms/project_collabnotes/hooks/useTreeDnd.ts
+++ b/javascript/src/frontend/collab_forms/forms/project_collabnotes/hooks/useTreeDnd.ts
@@ -8,6 +8,42 @@ interface UseTreeDndProps {
onTreeMutated: () => void;
}
+interface ClientRect {
+ top: number;
+ height: number;
+}
+
+/** Determine before/inside/after based on pointer Y within an item rect. */
+function computeDropPosition(
+ nodeType: "folder" | "note",
+ pointerY: number | null,
+ overRect: ClientRect | null,
+ deltaY: number,
+): DropPosition {
+ if (nodeType === "folder") {
+ if (pointerY != null && overRect && overRect.height > 0) {
+ const ratio = (pointerY - overRect.top) / overRect.height;
+ if (ratio < 0.25) return "before";
+ if (ratio > 0.75) return "after";
+ return "inside";
+ }
+ return "inside";
+ }
+ if (pointerY != null && overRect && overRect.height > 0) {
+ return (pointerY - overRect.top) < overRect.height * 0.5 ? "before" : "after";
+ }
+ return deltaY > 0 ? "after" : "before";
+}
+
+/** Calculate position for sentinel (tree-top / tree-bottom) drops. */
+function sentinelPosition(sentinelId: string, rootNodes: FlatNote[]): number {
+ if (rootNodes.length === 0) return 0;
+ const sorted = [...rootNodes].sort((a, b) => a.position - b.position);
+ return sentinelId === "tree-top"
+ ? sorted[0].position - 1000
+ : sorted[sorted.length - 1].position + 1000;
+}
+
export function useTreeDnd({ flatNodes, moveNote, onTreeMutated }: UseTreeDndProps) {
const [dragState, setDragState] = useState
({
activeId: null,
@@ -116,40 +152,15 @@ export function useTreeDnd({ flatNodes, moveNote, onTreeMutated }: UseTreeDndPro
return;
}
- // Compute pointer Y from initial activation point + drag delta
- let dropPosition: DropPosition;
const activatorY = (event.activatorEvent as PointerEvent)?.clientY;
const pointerY = activatorY != null ? activatorY + event.delta.y : null;
- const overRect = over.rect;
-
- if (overNode.nodeType === "folder") {
- // Folders: top 25% = before, middle 50% = inside, bottom 25% = after
- if (pointerY != null && overRect && overRect.height > 0) {
- const relY = pointerY - overRect.top;
- const ratio = relY / overRect.height;
- if (ratio < 0.25) {
- dropPosition = "before";
- } else if (ratio > 0.75) {
- dropPosition = "after";
- } else {
- dropPosition = "inside";
- }
- } else {
- dropPosition = "inside";
- }
- } else {
- // Notes: top half = before, bottom half = after
- if (pointerY != null && overRect && overRect.height > 0) {
- const relY = pointerY - overRect.top;
- dropPosition = relY < overRect.height * 0.5 ? "before" : "after";
- } else {
- dropPosition = event.delta.y > 0 ? "after" : "before";
- }
- }
+ let dropPosition = computeDropPosition(
+ overNode.nodeType, pointerY, over.rect, event.delta.y
+ );
if (!isValidDrop(activeId, overId, dropPosition)) {
if (dropPosition === "inside") {
- dropPosition = movingDown ? "after" : "before";
+ dropPosition = event.delta.y > 0 ? "after" : "before";
if (!isValidDrop(activeId, overId, dropPosition)) {
setDragState((prev) => ({ ...prev, overId: null, dropPosition: null }));
return;
@@ -183,15 +194,9 @@ export function useTreeDnd({ flatNodes, moveNote, onTreeMutated }: UseTreeDndPro
let newPosition: number;
if (overId === "tree-top" || overId === "tree-bottom") {
- const rootNodes = flatNodes
- .filter((n) => n.parentId === null)
- .sort((a, b) => a.position - b.position);
+ const rootNodes = flatNodes.filter((n) => n.parentId === null);
newParentId = null;
- if (overId === "tree-top") {
- newPosition = rootNodes.length > 0 ? rootNodes[0].position - 1000 : 0;
- } else {
- newPosition = rootNodes.length > 0 ? rootNodes[rootNodes.length - 1].position + 1000 : 0;
- }
+ newPosition = sentinelPosition(overId as string, rootNodes);
} else {
const overNode = flatNodes.find((n) => n.id === overId);
if (!overNode) return;
diff --git a/scripts/seed_test_data.py b/scripts/seed_test_data.py
index c89c214bb..f554bb34c 100644
--- a/scripts/seed_test_data.py
+++ b/scripts/seed_test_data.py
@@ -15,239 +15,234 @@
ProjectCollabNote,
ProjectCollabNoteField,
ProjectInvite,
- ProjectObjective,
ProjectScope,
ProjectTarget,
+ ProjectType,
+ ProjectRole,
)
-from ghostwriter.rolodex.models import ProjectType, ProjectRole
-from ghostwriter.reporting.models import Severity, FindingType
User = get_user_model()
-# ---------------------------------------------------------------------------
-# Users
-# ---------------------------------------------------------------------------
-users = {}
-user_specs = [
- ("alice", "alice@example.com", "Alice Rivera", "TestPass123!"),
- ("bob", "bob@example.com", "Bob Chen", "TestPass123!"),
- ("charlie", "charlie@example.com", "Charlie Okafor", "TestPass123!"),
-]
-for username, email, name, pw in user_specs:
- u, created = User.objects.get_or_create(
- username=username,
- defaults=dict(email=email, name=name, role="user"),
- )
- if created:
- u.set_password(pw)
- u.save()
- print(f" Created user: {username} / {pw}")
- else:
- print(f" User already exists: {username}")
- users[username] = u
-
-mgr, created = User.objects.get_or_create(
- username="manager",
- defaults=dict(email="mgr@example.com", name="Morgan Taylor", role="manager"),
-)
-if created:
- mgr.set_password("TestPass123!")
- mgr.save()
- print(" Created manager: manager / TestPass123!")
-else:
- print(" Manager already exists: manager")
-users["manager"] = mgr
-
-# ---------------------------------------------------------------------------
-# Clients
-# ---------------------------------------------------------------------------
-pt_pentest = ProjectType.objects.get(project_type="Penetration Test")
-pt_webapp = ProjectType.objects.get(project_type="Web Application Assessment")
-pt_redteam = ProjectType.objects.get(project_type="Red Team")
-role_lead = ProjectRole.objects.get(project_role="Assessment Lead")
-role_operator = ProjectRole.objects.get(project_role="Operator")
-
-clients_data = [
- {
- "name": "Acme Corporation",
- "short_name": "ACME",
- "codename": "THUNDERBOLT",
- "contacts": [
- ("Jane Doe", "jane.doe@acme.example.com", "CISO", "555-0101"),
- ("John Smith", "john.smith@acme.example.com", "IT Director", "555-0102"),
- ],
- "projects": [
- {
- "codename": "IRON FALCON",
- "project_type": pt_pentest,
- "lead": "alice",
- "operators": ["bob"],
- "start": date.today() - timedelta(days=14),
- "end": date.today() + timedelta(days=16),
- "scopes": ["10.10.0.0/16", "web.acme.example.com", "api.acme.example.com"],
- "targets": ["Domain Controller (DC01)", "Exchange Server", "VPN Gateway"],
- "notes_tree": [
- {"title": "Reconnaissance", "type": "folder", "children": [
- {"title": "External Recon", "type": "note", "content": "Ran subdomain enumeration against acme.example.com. Found 14 subdomains.
"},
- {"title": "OSINT Findings", "type": "note", "content": "LinkedIn reveals 3 IT admins. Password policy doc leaked on Pastebin (expired).
"},
- ]},
- {"title": "Exploitation", "type": "folder", "children": [
- {"title": "Initial Access", "type": "note", "content": "Phishing payload delivered via macro-enabled doc. User jane.doe clicked.
"},
- {"title": "Lateral Movement", "type": "note", "content": "Used Pass-the-Hash from workstation WS-042 to reach DC01.
"},
- ]},
- {"title": "Meeting Notes", "type": "note", "content": "Kickoff call 2026-03-17. Client wants focus on AD attack paths.
"},
- ],
- },
- ],
- },
- {
- "name": "Globex Industries",
- "short_name": "GLOBEX",
- "codename": "NIGHTSHADE",
- "contacts": [
- ("Hank Scorpio", "hank@globex.example.com", "CEO", "555-0200"),
- ],
- "projects": [
- {
- "codename": "SHADOW NEXUS",
- "project_type": pt_webapp,
- "lead": "bob",
- "operators": ["charlie"],
- "start": date.today() - timedelta(days=7),
- "end": date.today() + timedelta(days=23),
- "scopes": ["https://portal.globex.example.com", "https://api.globex.example.com/v2"],
- "targets": ["Customer Portal", "REST API v2", "Admin Panel"],
- "notes_tree": [
- {"title": "API Testing", "type": "folder", "children": [
- {"title": "Authentication Bypass", "type": "note", "content": "JWT validation can be bypassed by setting alg=none. Critical finding.
"},
- {"title": "IDOR on /users endpoint", "type": "note", "content": "Changing user_id in request allows access to other users' data.
"},
- ]},
- {"title": "Portal XSS", "type": "note", "content": "Stored XSS in profile bio field. Payload: <img src=x onerror=alert(1)>
"},
- ],
+def create_notes_tree(nodes, project, parent=None, pos_start=0):
+ """Recursively create a tree of collab notes from a list of dicts."""
+ for idx, node in enumerate(nodes):
+ position = (pos_start + idx) * 1000
+ note, _ = ProjectCollabNote.objects.get_or_create(
+ project=project,
+ title=node["title"],
+ parent=parent,
+ defaults={
+ "node_type": node["type"],
+ "content": node.get("content", ""),
+ "position": position,
},
- ],
- },
- {
- "name": "Initech",
- "short_name": "INIT",
- "codename": "REDSTAPLER",
- "contacts": [
- ("Bill Lumbergh", "bill@initech.example.com", "VP", "555-0300"),
- ("Milton Waddams", "milton@initech.example.com", "Facilities", "555-0301"),
- ],
- "projects": [
- {
- "codename": "CRIMSON TIDE",
- "project_type": pt_redteam,
- "lead": "charlie",
- "operators": ["alice", "bob"],
- "start": date.today(),
- "end": date.today() + timedelta(days=30),
- "scopes": ["*.initech.example.com", "10.20.0.0/16", "Physical: HQ Building"],
- "targets": ["CEO Laptop", "Financial Database", "Badge System"],
- "notes_tree": [
- {"title": "Planning", "type": "folder", "children": [
- {"title": "Rules of Engagement", "type": "note", "content": "No DoS. No production DB modification. Physical access authorized M-F 8am-6pm only.
"},
- {"title": "C2 Infrastructure", "type": "note", "content": "Cobalt Strike teamserver on AWS. Redirector chain: CloudFront → Nginx → TS.
"},
- ]},
- {"title": "Execution Log", "type": "folder", "children": []},
- ],
- },
- ],
- },
-]
-
-for cdata in clients_data:
- client, created = Client.objects.get_or_create(
- name=cdata["name"],
- defaults=dict(short_name=cdata["short_name"], codename=cdata["codename"]),
+ )
+ if node["type"] == "note" and node.get("content"):
+ ProjectCollabNoteField.objects.get_or_create(
+ note=note,
+ position=0,
+ defaults={
+ "field_type": "rich_text",
+ "content": node["content"],
+ },
+ )
+ if "children" in node:
+ create_notes_tree(node["children"], project, parent=note)
+
+
+def seed_users():
+ """Create test users and return a dict of username -> User."""
+ users = {}
+ user_specs = [
+ ("alice", "alice@example.com", "Alice Rivera"),
+ ("bob", "bob@example.com", "Bob Chen"),
+ ("charlie", "charlie@example.com", "Charlie Okafor"),
+ ]
+ for username, email, name in user_specs:
+ user, created = User.objects.get_or_create(
+ username=username,
+ defaults={"email": email, "name": name, "role": "user"},
+ )
+ if created:
+ user.set_password("TestPass123!")
+ user.save()
+ print(f" Created user: {username} / TestPass123!")
+ else:
+ print(f" User already exists: {username}")
+ users[username] = user
+
+ mgr, created = User.objects.get_or_create(
+ username="manager",
+ defaults={"email": "mgr@example.com", "name": "Morgan Taylor", "role": "manager"},
)
- action = "Created" if created else "Exists"
- print(f"\n{action} client: {client.name}")
-
- for cname, cemail, ctitle, cphone in cdata["contacts"]:
- ClientContact.objects.get_or_create(
- client=client,
- email=cemail,
- defaults=dict(name=cname, job_title=ctitle, phone=cphone),
+ if created:
+ mgr.set_password("TestPass123!")
+ mgr.save()
+ print(" Created manager: manager / TestPass123!")
+ else:
+ print(" Manager already exists: manager")
+ users["manager"] = mgr
+ return users
+
+
+def seed_clients(users):
+ """Create test clients, projects, assignments, and collab notes."""
+ pt_pentest = ProjectType.objects.get(project_type="Penetration Test")
+ pt_webapp = ProjectType.objects.get(project_type="Web Application Assessment")
+ pt_redteam = ProjectType.objects.get(project_type="Red Team")
+
+ role_lead = ProjectRole.objects.get(project_role="Assessment Lead")
+ role_operator = ProjectRole.objects.get(project_role="Operator")
+
+ clients_data = [
+ {
+ "name": "Acme Corporation",
+ "short_name": "ACME",
+ "codename": "THUNDERBOLT",
+ "contacts": [
+ ("Jane Doe", "jane.doe@acme.example.com", "CISO", "555-0101"),
+ ("John Smith", "john.smith@acme.example.com", "IT Director", "555-0102"),
+ ],
+ "projects": [
+ {
+ "codename": "IRON FALCON",
+ "project_type": pt_pentest,
+ "lead": "alice",
+ "operators": ["bob"],
+ "start": date.today() - timedelta(days=14),
+ "end": date.today() + timedelta(days=16),
+ "scopes": ["10.10.0.0/16", "web.acme.example.com", "api.acme.example.com"],
+ "targets": ["Domain Controller (DC01)", "Exchange Server", "VPN Gateway"],
+ "notes_tree": [
+ {"title": "Reconnaissance", "type": "folder", "children": [
+ {"title": "External Recon", "type": "note", "content": "Ran subdomain enumeration against acme.example.com. Found 14 subdomains.
"},
+ {"title": "OSINT Findings", "type": "note", "content": "LinkedIn reveals 3 IT admins. Password policy doc leaked on Pastebin (expired).
"},
+ ]},
+ {"title": "Exploitation", "type": "folder", "children": [
+ {"title": "Initial Access", "type": "note", "content": "Phishing payload delivered via macro-enabled doc. User jane.doe clicked.
"},
+ {"title": "Lateral Movement", "type": "note", "content": "Used Pass-the-Hash from workstation WS-042 to reach DC01.
"},
+ ]},
+ {"title": "Meeting Notes", "type": "note", "content": "Kickoff call 2026-03-17. Client wants focus on AD attack paths.
"},
+ ],
+ },
+ ],
+ },
+ {
+ "name": "Globex Industries",
+ "short_name": "GLOBEX",
+ "codename": "NIGHTSHADE",
+ "contacts": [
+ ("Hank Scorpio", "hank@globex.example.com", "CEO", "555-0200"),
+ ],
+ "projects": [
+ {
+ "codename": "SHADOW NEXUS",
+ "project_type": pt_webapp,
+ "lead": "bob",
+ "operators": ["charlie"],
+ "start": date.today() - timedelta(days=7),
+ "end": date.today() + timedelta(days=23),
+ "scopes": ["https://portal.globex.example.com", "https://api.globex.example.com/v2"],
+ "targets": ["Customer Portal", "REST API v2", "Admin Panel"],
+ "notes_tree": [
+ {"title": "API Testing", "type": "folder", "children": [
+ {"title": "Authentication Bypass", "type": "note", "content": "JWT validation can be bypassed by setting alg=none. Critical finding.
"},
+ {"title": "IDOR on /users endpoint", "type": "note", "content": "Changing user_id in request allows access to other users' data.
"},
+ ]},
+ {"title": "Portal XSS", "type": "note", "content": "Stored XSS in profile bio field. Payload: <img src=x onerror=alert(1)>
"},
+ ],
+ },
+ ],
+ },
+ {
+ "name": "Initech",
+ "short_name": "INIT",
+ "codename": "REDSTAPLER",
+ "contacts": [
+ ("Bill Lumbergh", "bill@initech.example.com", "VP", "555-0300"),
+ ("Milton Waddams", "milton@initech.example.com", "Facilities", "555-0301"),
+ ],
+ "projects": [
+ {
+ "codename": "CRIMSON TIDE",
+ "project_type": pt_redteam,
+ "lead": "charlie",
+ "operators": ["alice", "bob"],
+ "start": date.today(),
+ "end": date.today() + timedelta(days=30),
+ "scopes": ["*.initech.example.com", "10.20.0.0/16", "Physical: HQ Building"],
+ "targets": ["CEO Laptop", "Financial Database", "Badge System"],
+ "notes_tree": [
+ {"title": "Planning", "type": "folder", "children": [
+ {"title": "Rules of Engagement", "type": "note", "content": "No DoS. No production DB modification. Physical access authorized M-F 8am-6pm only.
"},
+ {"title": "C2 Infrastructure", "type": "note", "content": "Cobalt Strike teamserver on AWS. Redirector chain: CloudFront → Nginx → TS.
"},
+ ]},
+ {"title": "Execution Log", "type": "folder", "children": []},
+ ],
+ },
+ ],
+ },
+ ]
+
+ for cdata in clients_data:
+ client, created = Client.objects.get_or_create(
+ name=cdata["name"],
+ defaults={"short_name": cdata["short_name"], "codename": cdata["codename"]},
)
+ print(f"\n{'Created' if created else 'Exists'} client: {client.name}")
- # Invite all users to the client
- for u in users.values():
- ClientInvite.objects.get_or_create(client=client, user=u)
-
- for pdata in cdata["projects"]:
- project, created = Project.objects.get_or_create(
- codename=pdata["codename"],
- defaults=dict(
+ for cname, cemail, ctitle, cphone in cdata["contacts"]:
+ ClientContact.objects.get_or_create(
client=client,
- project_type=pdata["project_type"],
- start_date=pdata["start"],
- end_date=pdata["end"],
- complete=False,
- ),
- )
- action = "Created" if created else "Exists"
- print(f" {action} project: {project.codename}")
-
- # Assignments
- lead_user = users[pdata["lead"]]
- ProjectAssignment.objects.get_or_create(
- project=project, operator=lead_user,
- defaults=dict(role=role_lead, start_date=pdata["start"], end_date=pdata["end"]),
- )
- for op_name in pdata["operators"]:
- op_user = users[op_name]
- ProjectAssignment.objects.get_or_create(
- project=project, operator=op_user,
- defaults=dict(role=role_operator, start_date=pdata["start"], end_date=pdata["end"]),
+ email=cemail,
+ defaults={"name": cname, "job_title": ctitle, "phone": cphone},
)
- # Invite manager
- ProjectInvite.objects.get_or_create(project=project, user=users["manager"])
-
- # Scopes
- for i, scope_name in enumerate(pdata["scopes"]):
- ProjectScope.objects.get_or_create(
- project=project, name=scope_name,
- defaults=dict(scope=scope_name),
+ for user in users.values():
+ ClientInvite.objects.get_or_create(client=client, user=user)
+
+ for pdata in cdata["projects"]:
+ proj, proj_created = Project.objects.get_or_create(
+ codename=pdata["codename"],
+ defaults={
+ "client": client,
+ "project_type": pdata["project_type"],
+ "start_date": pdata["start"],
+ "end_date": pdata["end"],
+ "complete": False,
+ },
)
+ print(f" {'Created' if proj_created else 'Exists'} project: {proj.codename}")
- # Targets
- for i, target_name in enumerate(pdata["targets"]):
- ProjectTarget.objects.get_or_create(
- project=project, hostname=target_name,
+ lead_user = users[pdata["lead"]]
+ ProjectAssignment.objects.get_or_create(
+ project=proj, operator=lead_user,
+ defaults={"role": role_lead, "start_date": pdata["start"], "end_date": pdata["end"]},
)
+ for op_name in pdata["operators"]:
+ ProjectAssignment.objects.get_or_create(
+ project=proj, operator=users[op_name],
+ defaults={"role": role_operator, "start_date": pdata["start"], "end_date": pdata["end"]},
+ )
- # Collab notes tree
- def create_notes_tree(nodes, project, parent=None, pos_start=0):
- for i, node in enumerate(nodes):
- position = (pos_start + i) * 1000
- note, _ = ProjectCollabNote.objects.get_or_create(
- project=project,
- title=node["title"],
- parent=parent,
- defaults=dict(
- node_type=node["type"],
- content=node.get("content", ""),
- position=position,
- ),
+ ProjectInvite.objects.get_or_create(project=proj, user=users["manager"])
+
+ for scope_name in pdata["scopes"]:
+ ProjectScope.objects.get_or_create(
+ project=proj, name=scope_name,
+ defaults={"scope": scope_name},
)
- if node["type"] == "note" and node.get("content"):
- ProjectCollabNoteField.objects.get_or_create(
- note=note,
- position=0,
- defaults=dict(
- field_type="rich_text",
- content=node["content"],
- ),
- )
- if "children" in node:
- create_notes_tree(node["children"], project, parent=note)
- if created:
- create_notes_tree(pdata["notes_tree"], project)
+ for target_name in pdata["targets"]:
+ ProjectTarget.objects.get_or_create(project=proj, hostname=target_name)
+
+ if proj_created:
+ create_notes_tree(pdata["notes_tree"], proj)
+
+
+users = seed_users()
+seed_clients(users)
print("\n" + "=" * 60)
print("SEED DATA COMPLETE")