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"![Image {image_num}](missing)\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 ( +
+ + +
+ ); +} 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"} +
+ +
+
+
+
+ + setTitle(e.target.value)} + placeholder={`Enter ${type} title...`} + autoFocus + required + /> +
+
+
+ + +
+
+
+
+ ); +} 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. +

+
+
+ + +
+
+
+ ); +} 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" }} + > + Note attachment + {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 ( +
+
+ + + + {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 ( +
+
+ + + +
+ + + +
+ {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 && ( + <> + + + + )} + + + + )} +
+ + {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 @@