From 821663b79becd711649e299cbffbb7377769e928 Mon Sep 17 00:00:00 2001 From: Rudransh Shrivastava Date: Sun, 2 Aug 2026 20:49:40 +0530 Subject: [PATCH 01/30] add BoardCandidateProfile model and update code Add GraphQL node/query/mutation for the model. Also update existing claim mutations and queries, allow REJECTED claim/evidence to be visible to anyone. Update the owasp_sync_board_candidates command to sync the raw markdown text. Add missing make target for owasp_sync_board_candidates Signed-off-by: Rudransh Shrivastava --- backend/make/apps/owasp.mk | 6 +- backend/src/apps/owasp/admin/__init__.py | 1 + .../owasp/admin/board_candidate_profile.py | 40 +++++++ .../mutations/board_candidate_claim.py | 6 + .../internal/nodes/board_candidate_claim.py | 1 + .../internal/nodes/board_candidate_profile.py | 34 ++++++ .../owasp/api/internal/queries/__init__.py | 2 + .../internal/queries/board_candidate_claim.py | 29 ++++- .../queries/board_candidate_claim_evidence.py | 12 +- .../queries/board_candidate_profile.py | 40 +++++++ .../commands/owasp_sync_board_candidates.py | 26 ++++- ...oardcandidateclaim_source_text_and_more.py | 77 +++++++++++++ backend/src/apps/owasp/models/__init__.py | 1 + .../owasp/models/board_candidate_claim.py | 6 + .../owasp/models/board_candidate_profile.py | 32 ++++++ .../admin/board_candidate_profile_test.py | 62 ++++++++++ .../mutations/board_candidate_claim_test.py | 8 +- .../nodes/board_candidate_claim_test.py | 1 + .../board_candidate_claim_evidence_test.py | 103 +++++++++++++++++ .../queries/board_candidate_claim_test.py | 89 ++++++++++++++- .../owasp_sync_board_candidates_test.py | 108 ++++++++++++++++++ .../models/board_candidate_claim_test.py | 7 ++ .../models/board_candidate_profile_test.py | 31 +++++ 23 files changed, 706 insertions(+), 16 deletions(-) create mode 100644 backend/src/apps/owasp/admin/board_candidate_profile.py create mode 100644 backend/src/apps/owasp/api/internal/nodes/board_candidate_profile.py create mode 100644 backend/src/apps/owasp/api/internal/queries/board_candidate_profile.py create mode 100644 backend/src/apps/owasp/migrations/0081_boardcandidateclaim_source_text_and_more.py create mode 100644 backend/src/apps/owasp/models/board_candidate_profile.py create mode 100644 backend/tests/unit/apps/owasp/admin/board_candidate_profile_test.py create mode 100644 backend/tests/unit/apps/owasp/models/board_candidate_profile_test.py diff --git a/backend/make/apps/owasp.mk b/backend/make/apps/owasp.mk index 22e15ca8a0..ea02f679bd 100644 --- a/backend/make/apps/owasp.mk +++ b/backend/make/apps/owasp.mk @@ -2,7 +2,7 @@ owasp-aggregate-member-contributions owasp-aggregate-projects owasp-create-project-metadata-file \ owasp-enrich-chapters owasp-enrich-committees owasp-enrich-events owasp-enrich-projects \ owasp-generate-community-snapshot-video owasp-process-snapshots owasp-scrape-chapters \ - owasp-scrape-committees owasp-scrape-projects owasp-sync-posts owasp-update-events \ + owasp-scrape-committees owasp-scrape-projects owasp-sync-posts owasp-sync-board-candidates owasp-update-events \ owasp-update-leaders owasp-update-project-health-metrics owasp-update-project-health-requirements \ owasp-update-project-health-scores owasp-update-sponsors @@ -77,6 +77,10 @@ owasp-scrape-projects: owasp-sync-posts: @CMD="python manage.py owasp_sync_posts" $(MAKE) backend-exec-command +owasp-sync-board-candidates: + @echo "Sync OWASP board candidates" + @CMD="python manage.py owasp_sync_board_candidates $(ARGS)" $(MAKE) exec-backend-command + owasp-update-events: @echo "Getting OWASP events data" @CMD="python manage.py owasp_update_events" $(MAKE) backend-exec-command diff --git a/backend/src/apps/owasp/admin/__init__.py b/backend/src/apps/owasp/admin/__init__.py index 5d3904b397..7553bc848f 100644 --- a/backend/src/apps/owasp/admin/__init__.py +++ b/backend/src/apps/owasp/admin/__init__.py @@ -7,6 +7,7 @@ from .board_candidate_claim import BoardCandidateClaimAdmin from .board_candidate_claim_evidence import BoardCandidateClaimEvidenceAdmin from .board_candidate_claim_review import BoardCandidateClaimReviewAdmin +from .board_candidate_profile import BoardCandidateProfileAdmin from .board_of_directors import BoardOfDirectorsAdmin from .chapter import ChapterAdmin from .committee import CommitteeAdmin diff --git a/backend/src/apps/owasp/admin/board_candidate_profile.py b/backend/src/apps/owasp/admin/board_candidate_profile.py new file mode 100644 index 0000000000..8fb8ea521c --- /dev/null +++ b/backend/src/apps/owasp/admin/board_candidate_profile.py @@ -0,0 +1,40 @@ +"""Django admin configuration for BoardCandidateProfile model.""" + +from django.contrib import admin +from django.db import models + +from apps.owasp.models.board_candidate_profile import BoardCandidateProfile + + +class BoardCandidateProfileAdmin(admin.ModelAdmin): + """Admin for BoardCandidateProfile model.""" + + list_display = ( + "__str__", + "nest_created_at", + "nest_updated_at", + ) + search_fields = ( + "candidate__member_name", + "candidate__member__login", + "raw_markdown", + ) + readonly_fields = ( + "nest_created_at", + "nest_updated_at", + ) + + def get_queryset(self, request) -> models.QuerySet: + """Retrieve optimized queryset with related candidate. + + Args: + request: The HTTP request object. + + Returns: + QuerySet: BoardCandidateProfile queryset with prefetched candidate. + + """ + return super().get_queryset(request).select_related("candidate__member") + + +admin.site.register(BoardCandidateProfile, BoardCandidateProfileAdmin) diff --git a/backend/src/apps/owasp/api/internal/mutations/board_candidate_claim.py b/backend/src/apps/owasp/api/internal/mutations/board_candidate_claim.py index cf8d4f17d5..0c1171ae5a 100644 --- a/backend/src/apps/owasp/api/internal/mutations/board_candidate_claim.py +++ b/backend/src/apps/owasp/api/internal/mutations/board_candidate_claim.py @@ -27,6 +27,7 @@ class CreateClaimInput: description: str name: str + source_text: str = "" year: int @@ -37,6 +38,7 @@ class UpdateClaimInput: description: str | None = None key: str name: str | None = None + source_text: str | None = None year: int @@ -173,6 +175,7 @@ def create_board_candidate_claim( candidate=candidate, description=input_data.description, name=input_data.name, + source_text=input_data.source_text, ) except IntegrityError: logger.warning( @@ -232,6 +235,9 @@ def update_board_candidate_claim( if input_data.description: claim.description = input_data.description update_fields.append("description") + if input_data.source_text is not None: + claim.source_text = input_data.source_text + update_fields.append("source_text") try: claim.save(update_fields=update_fields) diff --git a/backend/src/apps/owasp/api/internal/nodes/board_candidate_claim.py b/backend/src/apps/owasp/api/internal/nodes/board_candidate_claim.py index 62bceb9b2c..c950fff7d4 100644 --- a/backend/src/apps/owasp/api/internal/nodes/board_candidate_claim.py +++ b/backend/src/apps/owasp/api/internal/nodes/board_candidate_claim.py @@ -21,6 +21,7 @@ "key", "name", "order", + "source_text", "withdrawn_at", "withdrawn_reason", ], diff --git a/backend/src/apps/owasp/api/internal/nodes/board_candidate_profile.py b/backend/src/apps/owasp/api/internal/nodes/board_candidate_profile.py new file mode 100644 index 0000000000..1a565e0c52 --- /dev/null +++ b/backend/src/apps/owasp/api/internal/nodes/board_candidate_profile.py @@ -0,0 +1,34 @@ +"""OWASP Board Candidate Profile GraphQL node.""" + +from datetime import datetime + +import strawberry +import strawberry_django + +from apps.owasp.api.internal.nodes.entity_member import EntityMemberNode +from apps.owasp.models.board_candidate_profile import BoardCandidateProfile + + +@strawberry_django.type( + BoardCandidateProfile, + fields=[ + "raw_markdown", + ], +) +class BoardCandidateProfileNode(strawberry.relay.Node): + """Board Candidate Profile node.""" + + @strawberry_django.field + def candidate(self, root: BoardCandidateProfile) -> EntityMemberNode: + """Resolve candidate.""" + return root.candidate + + @strawberry_django.field + def created_at(self, root: BoardCandidateProfile) -> datetime: + """Resolve profile creation date.""" + return root.nest_created_at + + @strawberry_django.field + def updated_at(self, root: BoardCandidateProfile) -> datetime: + """Resolve profile last update date.""" + return root.nest_updated_at diff --git a/backend/src/apps/owasp/api/internal/queries/__init__.py b/backend/src/apps/owasp/api/internal/queries/__init__.py index fb62f625ea..c737f40723 100644 --- a/backend/src/apps/owasp/api/internal/queries/__init__.py +++ b/backend/src/apps/owasp/api/internal/queries/__init__.py @@ -4,6 +4,7 @@ from apps.owasp.api.internal.queries.board_candidate_claim_evidence import ( BoardCandidateClaimEvidenceQuery, ) +from apps.owasp.api.internal.queries.board_candidate_profile import BoardCandidateProfileQuery from .board_of_directors import BoardOfDirectorsQuery from .chapter import ChapterQuery @@ -21,6 +22,7 @@ class OwaspQuery( BoardCandidateClaimEvidenceQuery, BoardCandidateClaimQuery, + BoardCandidateProfileQuery, BoardOfDirectorsQuery, ChapterQuery, CommitteeQuery, diff --git a/backend/src/apps/owasp/api/internal/queries/board_candidate_claim.py b/backend/src/apps/owasp/api/internal/queries/board_candidate_claim.py index 1cac976733..7636d6a89d 100644 --- a/backend/src/apps/owasp/api/internal/queries/board_candidate_claim.py +++ b/backend/src/apps/owasp/api/internal/queries/board_candidate_claim.py @@ -47,12 +47,18 @@ def board_candidate_claims( claims = claims.filter(candidate__member__login=login) if not is_self and not is_reviewer: - claims = claims.filter(status=BoardCandidateClaim.Status.APPROVED) + claims = claims.filter( + status__in=[ + BoardCandidateClaim.Status.APPROVED, + BoardCandidateClaim.Status.REJECTED, + ] + ) elif is_reviewer and not is_self: claims = claims.filter( status__in=[ BoardCandidateClaim.Status.SUBMITTED, BoardCandidateClaim.Status.APPROVED, + BoardCandidateClaim.Status.REJECTED, ] ) elif is_reviewer: @@ -62,16 +68,27 @@ def board_candidate_claims( status__in=[ BoardCandidateClaim.Status.SUBMITTED, BoardCandidateClaim.Status.APPROVED, + BoardCandidateClaim.Status.REJECTED, ] ) ) elif user.is_authenticated and user.github_user: claims = claims.filter( Q(candidate__member=user.github_user) - | Q(status=BoardCandidateClaim.Status.APPROVED) + | Q( + status__in=[ + BoardCandidateClaim.Status.APPROVED, + BoardCandidateClaim.Status.REJECTED, + ] + ) ) else: - claims = claims.filter(status=BoardCandidateClaim.Status.APPROVED) + claims = claims.filter( + status__in=[ + BoardCandidateClaim.Status.APPROVED, + BoardCandidateClaim.Status.REJECTED, + ] + ) return ( claims.annotate( @@ -133,7 +150,11 @@ def board_candidate_claim( if ( is_self or (is_reviewer and claim.status == BoardCandidateClaim.Status.SUBMITTED) - or claim.status == BoardCandidateClaim.Status.APPROVED + or claim.status + in { + BoardCandidateClaim.Status.APPROVED, + BoardCandidateClaim.Status.REJECTED, + } ) else None ) diff --git a/backend/src/apps/owasp/api/internal/queries/board_candidate_claim_evidence.py b/backend/src/apps/owasp/api/internal/queries/board_candidate_claim_evidence.py index dfecf8a096..a30926f5aa 100644 --- a/backend/src/apps/owasp/api/internal/queries/board_candidate_claim_evidence.py +++ b/backend/src/apps/owasp/api/internal/queries/board_candidate_claim_evidence.py @@ -52,7 +52,11 @@ def get_claim_evidence( if ( is_self or (is_reviewer and evidence.claim.status == BoardCandidateClaim.Status.SUBMITTED) - or evidence.claim.status == BoardCandidateClaim.Status.APPROVED + or evidence.claim.status + in { + BoardCandidateClaim.Status.APPROVED, + BoardCandidateClaim.Status.REJECTED, + } ) else None ) @@ -99,7 +103,11 @@ def board_candidate_claim_evidences( if ( is_self or (is_reviewer and claim.status == BoardCandidateClaim.Status.SUBMITTED) - or claim.status == BoardCandidateClaim.Status.APPROVED + or claim.status + in { + BoardCandidateClaim.Status.APPROVED, + BoardCandidateClaim.Status.REJECTED, + } ) else [] ) diff --git a/backend/src/apps/owasp/api/internal/queries/board_candidate_profile.py b/backend/src/apps/owasp/api/internal/queries/board_candidate_profile.py new file mode 100644 index 0000000000..4ba0f1b674 --- /dev/null +++ b/backend/src/apps/owasp/api/internal/queries/board_candidate_profile.py @@ -0,0 +1,40 @@ +"""OWASP Board Candidate Profile GraphQL queries.""" + +import strawberry +import strawberry_django +from django.contrib.contenttypes.models import ContentType + +from apps.owasp.api.internal.nodes.board_candidate_profile import BoardCandidateProfileNode +from apps.owasp.models.board_candidate_profile import BoardCandidateProfile +from apps.owasp.models.board_of_directors import BoardOfDirectors + + +@strawberry.type +class BoardCandidateProfileQuery: + """GraphQL queries for Board Candidate Profile model.""" + + @strawberry_django.field + def board_candidate_profile( + self, info: strawberry.Info, login: str, year: int + ) -> BoardCandidateProfileNode | None: + """Resolve Board Candidate Profile. + + Args: + info (Info): Strawberry Info. + login (str): The login of the candidate. + year (int): The year of the election. + + Returns: + BoardCandidateProfileNode object if found, None otherwise. + + """ + try: + board = BoardOfDirectors.objects.get(year=year) + content_type = ContentType.objects.get_for_model(BoardOfDirectors) + return BoardCandidateProfile.objects.select_related("candidate__member").get( + candidate__member__login=login, + candidate__entity_type=content_type, + candidate__entity_id=board.id, + ) + except (BoardOfDirectors.DoesNotExist, BoardCandidateProfile.DoesNotExist): + return None diff --git a/backend/src/apps/owasp/management/commands/owasp_sync_board_candidates.py b/backend/src/apps/owasp/management/commands/owasp_sync_board_candidates.py index 5a0546535d..5336ff3536 100644 --- a/backend/src/apps/owasp/management/commands/owasp_sync_board_candidates.py +++ b/backend/src/apps/owasp/management/commands/owasp_sync_board_candidates.py @@ -9,6 +9,7 @@ from django.core.management.base import BaseCommand from apps.github.utils import get_repository_file_content +from apps.owasp.models.board_candidate_profile import BoardCandidateProfile from apps.owasp.models.board_of_directors import BoardOfDirectors from apps.owasp.models.entity_member import EntityMember @@ -66,6 +67,23 @@ def parse_candidate_metadata(self, content: str) -> dict: return {} + def parse_candidate_profile(self, content: str) -> str: + """Parse profile raw text content without YAML frontmatter from candidate markdown file. + + Args: + content (str): The markdown file content. + + Returns: + str: Parsed profile raw text. + + """ + yaml_pattern = re.compile(r"^---\s*\n((?:(?!^---\s*$).*\n)+)^---\s*$", re.MULTILINE) + + if not content.startswith("---"): + return content.strip() + + return yaml_pattern.sub("", content, count=1).strip() + def sync_year_candidates(self, year: int) -> int: """Sync candidates for a specific year. @@ -128,7 +146,13 @@ def sync_year_candidates(self, year: int) -> int: "order": 0, } - EntityMember.update_data(data, save=True) + member = EntityMember.update_data(data, save=True) + raw_markdown = self.parse_candidate_profile(file_content) + BoardCandidateProfile.objects.update_or_create( + candidate=member, + defaults={"raw_markdown": raw_markdown}, + ) + synced_count += 1 return synced_count diff --git a/backend/src/apps/owasp/migrations/0081_boardcandidateclaim_source_text_and_more.py b/backend/src/apps/owasp/migrations/0081_boardcandidateclaim_source_text_and_more.py new file mode 100644 index 0000000000..3b34231290 --- /dev/null +++ b/backend/src/apps/owasp/migrations/0081_boardcandidateclaim_source_text_and_more.py @@ -0,0 +1,77 @@ +# Generated by Django 6.0.6 on 2026-08-01 12:21 + +import django.core.validators +import django.db.models.deletion +from django.db import migrations, models + +import apps.owasp.models.board_candidate_claim_evidence +import apps.owasp.validators + + +class Migration(migrations.Migration): + dependencies = [ + ("owasp", "0080_boardcandidateclaimreview_boardofdirectors_reviewers_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="boardcandidateclaim", + name="source_text", + field=models.TextField( + blank=True, + default="", + help_text="The exact text excerpt from the candidate's profile this claim refers to.", + verbose_name="Source text", + ), + ), + migrations.AlterField( + model_name="boardcandidateclaimevidence", + name="file", + field=models.FileField( + blank=True, + null=True, + upload_to=apps.owasp.models.board_candidate_claim_evidence.uuid_upload_to, + validators=[ + django.core.validators.FileExtensionValidator( + allowed_extensions=["jpeg", "jpg", "pdf", "png", "webp"] + ), + apps.owasp.validators.validate_evidence_file_size, + ], + verbose_name="File", + ), + ), + migrations.CreateModel( + name="BoardCandidateProfile", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("nest_created_at", models.DateTimeField(auto_now_add=True)), + ("nest_updated_at", models.DateTimeField(auto_now=True)), + ( + "raw_markdown", + models.TextField( + blank=True, + default="", + help_text="The raw markdown content of the candidate's profile.", + ), + ), + ( + "candidate", + models.OneToOneField( + help_text="The candidate this profile belongs to.", + on_delete=django.db.models.deletion.CASCADE, + related_name="board_profile", + to="owasp.entitymember", + ), + ), + ], + options={ + "verbose_name_plural": "Board Candidate Profiles", + "db_table": "owasp_board_candidate_profile", + }, + ), + ] diff --git a/backend/src/apps/owasp/models/__init__.py b/backend/src/apps/owasp/models/__init__.py index 651ca4d0e6..ef23aa3cac 100644 --- a/backend/src/apps/owasp/models/__init__.py +++ b/backend/src/apps/owasp/models/__init__.py @@ -1,6 +1,7 @@ from .board_candidate_claim import BoardCandidateClaim from .board_candidate_claim_evidence import BoardCandidateClaimEvidence from .board_candidate_claim_review import BoardCandidateClaimReview +from .board_candidate_profile import BoardCandidateProfile from .board_of_directors import BoardOfDirectors from .chapter import Chapter from .committee import Committee diff --git a/backend/src/apps/owasp/models/board_candidate_claim.py b/backend/src/apps/owasp/models/board_candidate_claim.py index 88b93bb221..0460d98e45 100644 --- a/backend/src/apps/owasp/models/board_candidate_claim.py +++ b/backend/src/apps/owasp/models/board_candidate_claim.py @@ -75,6 +75,12 @@ class Status(models.TextChoices): verbose_name="Order", help_text="Display order of the claim within the candidate profile.", ) + source_text = models.TextField( + blank=True, + default="", + help_text="The exact text string from the candidate's profile this claim refers to.", + verbose_name="Source text", + ) status = models.CharField( choices=Status.choices, default=Status.DRAFT, diff --git a/backend/src/apps/owasp/models/board_candidate_profile.py b/backend/src/apps/owasp/models/board_candidate_profile.py new file mode 100644 index 0000000000..51947f8531 --- /dev/null +++ b/backend/src/apps/owasp/models/board_candidate_profile.py @@ -0,0 +1,32 @@ +"""OWASP app Board Candidate Profile model.""" + +from django.db import models + +from apps.common.models import TimestampedModel +from apps.owasp.models.entity_member import EntityMember + + +class BoardCandidateProfile(TimestampedModel): + """Model representing a Board Candidate Profile's markdown content.""" + + class Meta: + """Model options.""" + + db_table = "owasp_board_candidate_profile" + verbose_name_plural = "Board Candidate Profiles" + + candidate = models.OneToOneField( + EntityMember, + help_text="The candidate this profile belongs to.", + on_delete=models.CASCADE, + related_name="board_profile", + ) + raw_markdown = models.TextField( + blank=True, + default="", + help_text="The raw markdown content of the candidate's profile.", + ) + + def __str__(self) -> str: + """Return a string representation of the Board Candidate Profile.""" + return f"Profile for {self.candidate.member_name}" diff --git a/backend/tests/unit/apps/owasp/admin/board_candidate_profile_test.py b/backend/tests/unit/apps/owasp/admin/board_candidate_profile_test.py new file mode 100644 index 0000000000..560d202e93 --- /dev/null +++ b/backend/tests/unit/apps/owasp/admin/board_candidate_profile_test.py @@ -0,0 +1,62 @@ +"""Tests for BoardCandidateProfile admin.""" + +from unittest import mock +from unittest.mock import MagicMock, Mock + +from django.contrib.admin.sites import AdminSite + +from apps.owasp.admin.board_candidate_profile import BoardCandidateProfileAdmin +from apps.owasp.models.board_candidate_profile import BoardCandidateProfile + + +class TestBoardCandidateProfileAdmin: + """Tests for BoardCandidateProfileAdmin.""" + + def test_list_display(self) -> None: + """Test list_display is configured properly.""" + admin = BoardCandidateProfileAdmin(BoardCandidateProfile, AdminSite()) + + expected_fields = ( + "__str__", + "nest_created_at", + "nest_updated_at", + ) + assert admin.list_display == expected_fields + + def test_search_fields(self) -> None: + """Test search_fields is configured properly.""" + admin = BoardCandidateProfileAdmin(BoardCandidateProfile, AdminSite()) + + expected_search = ( + "candidate__member_name", + "candidate__member__login", + "raw_markdown", + ) + assert admin.search_fields == expected_search + + def test_readonly_fields(self) -> None: + """Test readonly_fields is configured properly.""" + admin = BoardCandidateProfileAdmin(BoardCandidateProfile, AdminSite()) + + expected_readonly = ( + "nest_created_at", + "nest_updated_at", + ) + assert admin.readonly_fields == expected_readonly + + def test_get_queryset(self) -> None: + """Test get_queryset applies select_related for candidate.""" + admin = BoardCandidateProfileAdmin(BoardCandidateProfile, AdminSite()) + mock_request = Mock() + + admin_queryset = MagicMock() + result_queryset = MagicMock() + admin_queryset.select_related.return_value = result_queryset + + with mock.patch.object( + admin.__class__.__bases__[0], "get_queryset", return_value=admin_queryset + ): + result = admin.get_queryset(mock_request) + + admin_queryset.select_related.assert_called_once_with("candidate__member") + assert result == result_queryset diff --git a/backend/tests/unit/apps/owasp/api/internal/mutations/board_candidate_claim_test.py b/backend/tests/unit/apps/owasp/api/internal/mutations/board_candidate_claim_test.py index e88e080f62..b4e94800eb 100644 --- a/backend/tests/unit/apps/owasp/api/internal/mutations/board_candidate_claim_test.py +++ b/backend/tests/unit/apps/owasp/api/internal/mutations/board_candidate_claim_test.py @@ -516,11 +516,14 @@ def test_reorder_claims_mixed_boards(self, mock_claim_model): class TestCreateBoardCandidateClaim: """Tests for create_board_candidate_claim mutation.""" - def _make_input_data(self, name="Test Claim", description="Test description", year=2025): + def _make_input_data( + self, name="Test Claim", description="Test description", year=2025, source_text="" + ): data = MagicMock() data.name = name data.description = description data.year = year + data.source_text = source_text return data @patch("apps.owasp.api.internal.mutations.board_candidate_claim.BoardOfDirectors") @@ -549,6 +552,7 @@ def test_create_claim_success(self, mock_claim_model, mock_board_model): candidate=mock_candidate, description=input_data.description, name=input_data.name, + source_text=input_data.source_text, ) assert result.ok assert result.code == "SUCCESS" @@ -706,7 +710,7 @@ def test_update_claim_partial(self, mock_claim_model): mock_github_user = MagicMock() user.github_user = mock_github_user info = _make_info(user) - input_data = MagicMock(key="test-key", description=None, year=2025) + input_data = MagicMock(key="test-key", description=None, year=2025, source_text=None) input_data.name = "Updated Name" claim = MagicMock() diff --git a/backend/tests/unit/apps/owasp/api/internal/nodes/board_candidate_claim_test.py b/backend/tests/unit/apps/owasp/api/internal/nodes/board_candidate_claim_test.py index b2a4fda1bd..5900ec6507 100644 --- a/backend/tests/unit/apps/owasp/api/internal/nodes/board_candidate_claim_test.py +++ b/backend/tests/unit/apps/owasp/api/internal/nodes/board_candidate_claim_test.py @@ -30,6 +30,7 @@ def test_node_fields(self): "key", "name", "order", + "source_text", "reviews", "status", "updated_at", diff --git a/backend/tests/unit/apps/owasp/api/internal/queries/board_candidate_claim_evidence_test.py b/backend/tests/unit/apps/owasp/api/internal/queries/board_candidate_claim_evidence_test.py index eeef2b9a08..f2339f5c49 100644 --- a/backend/tests/unit/apps/owasp/api/internal/queries/board_candidate_claim_evidence_test.py +++ b/backend/tests/unit/apps/owasp/api/internal/queries/board_candidate_claim_evidence_test.py @@ -152,6 +152,32 @@ def test_board_candidate_claim_evidences_reviewer_sees_submitted(self, mock_clai claim.evidences.filter.assert_called_once_with(is_removed=False) assert result == evidences_qs + @patch("apps.owasp.api.internal.queries.board_candidate_claim_evidence.BoardCandidateClaim") + def test_board_candidate_claim_evidences_non_self_rejected(self, mock_claim_model): + mock_claim_model.Status = BoardCandidateClaim.Status + user = MagicMock() + user.is_authenticated = True + user.github_user = MagicMock() + info = _make_info(user) + claim_key = "my-key" + login = "alice" + + claim = MagicMock() + claim.board.reviewers.filter.return_value.exists.return_value = False + claim.candidate.member = None + claim.status = BoardCandidateClaim.Status.REJECTED + evidences_qs = MagicMock() + claim.evidences.filter.return_value = evidences_qs + mock_claim_model.objects.filter.return_value.first.return_value = claim + + query = BoardCandidateClaimEvidenceQuery() + result = query.board_candidate_claim_evidences( + info, claim_key=claim_key, login=login, year=2025 + ) + + claim.evidences.filter.assert_called_once_with(is_removed=False) + assert result == evidences_qs + class TestBoardCandidateClaimEvidenceSingleQuery: """Tests for board_candidate_claim_evidence single evidence query.""" @@ -282,6 +308,56 @@ def test_board_candidate_claim_evidence_reviewer_sees_submitted(self): assert result == evidence + def test_board_candidate_claim_evidence_non_self_rejected(self): + user = MagicMock() + user.is_authenticated = True + user.github_user = MagicMock() + info = _make_info(user) + + evidence = MagicMock() + evidence.claim.board.reviewers.filter.return_value.exists.return_value = False + evidence.claim.candidate.member = None + evidence.claim.status = BoardCandidateClaim.Status.REJECTED + + with patch( + "apps.owasp.api.internal.queries.board_candidate_claim_evidence" + ".BoardCandidateClaimEvidence" + ) as mock_evidence_model: + mock_evidence_model.DoesNotExist = BoardCandidateClaimEvidence.DoesNotExist + mock_evidence_model.objects.get.return_value = evidence + + query = BoardCandidateClaimEvidenceQuery() + result = query.board_candidate_claim_evidence( + info, claim_key="test-key", key="ev-key", login="alice", year=2025 + ) + + assert result == evidence + + def test_board_candidate_claim_evidence_reviewer_sees_rejected(self): + user = MagicMock() + user.is_authenticated = True + user.github_user = MagicMock() + info = _make_info(user) + + evidence = MagicMock() + evidence.claim.board.reviewers.filter.return_value.exists.return_value = True + evidence.claim.candidate.member = None + evidence.claim.status = BoardCandidateClaim.Status.REJECTED + + with patch( + "apps.owasp.api.internal.queries.board_candidate_claim_evidence" + ".BoardCandidateClaimEvidence" + ) as mock_evidence_model: + mock_evidence_model.DoesNotExist = BoardCandidateClaimEvidence.DoesNotExist + mock_evidence_model.objects.get.return_value = evidence + + query = BoardCandidateClaimEvidenceQuery() + result = query.board_candidate_claim_evidence( + info, claim_key="test-key", key="ev-key", login="alice", year=2025 + ) + + assert result == evidence + class TestBoardCandidateClaimEvidenceFileUrlQuery: """Tests for board_candidate_claim_evidence_file_url query.""" @@ -445,3 +521,30 @@ def test_file_url_reviewer_accessible(self): ) assert result == "https://example.com/media/test.pdf" + + def test_file_url_anonymous_rejected(self): + user = MagicMock() + user.is_authenticated = False + info = _make_info(user) + + evidence = MagicMock() + evidence.claim.status = BoardCandidateClaim.Status.REJECTED + evidence.file = MagicMock() + evidence.file.url = "/media/test.pdf" + + with patch( + "apps.owasp.api.internal.queries.board_candidate_claim_evidence" + ".BoardCandidateClaimEvidence" + ) as mock_evidence_model: + mock_evidence_model.DoesNotExist = BoardCandidateClaimEvidence.DoesNotExist + mock_evidence_model.objects.get.return_value = evidence + info.context.request.build_absolute_uri.return_value = ( + "https://example.com/media/test.pdf" + ) + + query = BoardCandidateClaimEvidenceQuery() + result = query.board_candidate_claim_evidence_file_url( + info, claim_key="test-key", key="ev-key", login="alice", year=2025 + ) + + assert result == "https://example.com/media/test.pdf" diff --git a/backend/tests/unit/apps/owasp/api/internal/queries/board_candidate_claim_test.py b/backend/tests/unit/apps/owasp/api/internal/queries/board_candidate_claim_test.py index 560d4f63d0..3565c2ab2a 100644 --- a/backend/tests/unit/apps/owasp/api/internal/queries/board_candidate_claim_test.py +++ b/backend/tests/unit/apps/owasp/api/internal/queries/board_candidate_claim_test.py @@ -44,7 +44,7 @@ def test_board_candidate_claims_self(self, mock_claim_model, mock_board_model): @patch("apps.owasp.api.internal.queries.board_candidate_claim.BoardOfDirectors") @patch("apps.owasp.api.internal.queries.board_candidate_claim.BoardCandidateClaim") - def test_board_candidate_claims_non_self_filters_approved( + def test_board_candidate_claims_non_self_filters_approved_and_rejected( self, mock_claim_model, mock_board_model ): mock_claim_model.Status = BoardCandidateClaim.Status @@ -69,12 +69,17 @@ def test_board_candidate_claims_non_self_filters_approved( result = query.board_candidate_claims(info, login="alice", year=2025) base_qs.filter.assert_called_once_with(candidate__member__login="alice") - login_qs.filter.assert_called_once_with(status=BoardCandidateClaim.Status.APPROVED) + login_qs.filter.assert_called_once_with( + status__in=[ + BoardCandidateClaim.Status.APPROVED, + BoardCandidateClaim.Status.REJECTED, + ] + ) assert result == filtered_qs @patch("apps.owasp.api.internal.queries.board_candidate_claim.BoardOfDirectors") @patch("apps.owasp.api.internal.queries.board_candidate_claim.BoardCandidateClaim") - def test_board_candidate_claims_anonymous_filters_approved( + def test_board_candidate_claims_anonymous_filters_approved_and_rejected( self, mock_claim_model, mock_board_model ): mock_claim_model.Status = BoardCandidateClaim.Status @@ -97,12 +102,17 @@ def test_board_candidate_claims_anonymous_filters_approved( result = query.board_candidate_claims(info, login="alice", year=2025) base_qs.filter.assert_called_once_with(candidate__member__login="alice") - login_qs.filter.assert_called_once_with(status=BoardCandidateClaim.Status.APPROVED) + login_qs.filter.assert_called_once_with( + status__in=[ + BoardCandidateClaim.Status.APPROVED, + BoardCandidateClaim.Status.REJECTED, + ] + ) assert result == filtered_qs @patch("apps.owasp.api.internal.queries.board_candidate_claim.BoardOfDirectors") @patch("apps.owasp.api.internal.queries.board_candidate_claim.BoardCandidateClaim") - def test_board_candidate_claims_reviewer_sees_submitted_and_approved( + def test_board_candidate_claims_reviewer_sees_submitted_approved_and_rejected( self, mock_claim_model, mock_board_model ): mock_claim_model.Status = BoardCandidateClaim.Status @@ -128,7 +138,11 @@ def test_board_candidate_claims_reviewer_sees_submitted_and_approved( base_qs.filter.assert_called_once_with(candidate__member__login="alice") login_qs.filter.assert_called_once_with( - status__in=[BoardCandidateClaim.Status.SUBMITTED, BoardCandidateClaim.Status.APPROVED] + status__in=[ + BoardCandidateClaim.Status.SUBMITTED, + BoardCandidateClaim.Status.APPROVED, + BoardCandidateClaim.Status.REJECTED, + ] ) assert result == filtered_qs @@ -270,6 +284,69 @@ def test_board_candidate_claim_reviewer_sees_submitted(self, mock_claim_model): assert result == claim + @patch("apps.owasp.api.internal.queries.board_candidate_claim.BoardCandidateClaim") + def test_board_candidate_claim_non_self_rejected(self, mock_claim_model): + mock_claim_model.Status = BoardCandidateClaim.Status + mock_claim_model.DoesNotExist = BoardCandidateClaim.DoesNotExist + user = MagicMock() + user.is_authenticated = True + user.github_user = MagicMock() + info = _make_info(user) + + claim = MagicMock() + claim.board.reviewers.filter.return_value.exists.return_value = False + claim.candidate.member = None + claim.status = BoardCandidateClaim.Status.REJECTED + mock_qs = MagicMock() + mock_qs.get.return_value = claim + mock_claim_model.objects.select_related.return_value.annotate.return_value = mock_qs + + query = BoardCandidateClaimQuery() + result = query.board_candidate_claim(info, login="alice", key="test-key", year=2025) + + assert result == claim + + @patch("apps.owasp.api.internal.queries.board_candidate_claim.BoardCandidateClaim") + def test_board_candidate_claim_anonymous_rejected(self, mock_claim_model): + mock_claim_model.Status = BoardCandidateClaim.Status + mock_claim_model.DoesNotExist = BoardCandidateClaim.DoesNotExist + user = MagicMock() + user.is_authenticated = False + info = _make_info(user) + + claim = MagicMock() + claim.status = BoardCandidateClaim.Status.REJECTED + mock_qs = MagicMock() + mock_qs.get.return_value = claim + mock_claim_model.objects.select_related.return_value.annotate.return_value = mock_qs + + query = BoardCandidateClaimQuery() + result = query.board_candidate_claim(info, login="alice", key="test-key", year=2025) + + assert result == claim + + @patch("apps.owasp.api.internal.queries.board_candidate_claim.BoardCandidateClaim") + def test_board_candidate_claim_reviewer_sees_rejected(self, mock_claim_model): + mock_claim_model.Status = BoardCandidateClaim.Status + mock_claim_model.DoesNotExist = BoardCandidateClaim.DoesNotExist + user = MagicMock() + user.is_authenticated = True + user.github_user = MagicMock() + info = _make_info(user) + + claim = MagicMock() + claim.board.reviewers.filter.return_value.exists.return_value = True + claim.candidate.member = None + claim.status = BoardCandidateClaim.Status.REJECTED + mock_qs = MagicMock() + mock_qs.get.return_value = claim + mock_claim_model.objects.select_related.return_value.annotate.return_value = mock_qs + + query = BoardCandidateClaimQuery() + result = query.board_candidate_claim(info, login="alice", key="test-key", year=2025) + + assert result == claim + @patch("apps.owasp.api.internal.queries.board_candidate_claim.BoardCandidateClaim") def test_board_candidate_claim_reviewer_blocked_from_draft(self, mock_claim_model): mock_claim_model.Status = BoardCandidateClaim.Status diff --git a/backend/tests/unit/apps/owasp/management/commands/owasp_sync_board_candidates_test.py b/backend/tests/unit/apps/owasp/management/commands/owasp_sync_board_candidates_test.py index 08d04f3005..7f0bbe339a 100644 --- a/backend/tests/unit/apps/owasp/management/commands/owasp_sync_board_candidates_test.py +++ b/backend/tests/unit/apps/owasp/management/commands/owasp_sync_board_candidates_test.py @@ -79,6 +79,65 @@ def test_parse_candidate_metadata_no_yaml_match(self, command): metadata = command.parse_candidate_metadata(content) assert metadata == {} + def test_parse_candidate_profile_valid(self, command): + """Test parse_candidate_profile successfully strips valid YAML frontmatter.""" + content = """--- +name: John Doe +email: john.doe@example.com +--- + +# Candidate Statement + +I am running for the board.""" + + profile_text = command.parse_candidate_profile(content) + + assert profile_text == "# Candidate Statement\n\nI am running for the board." + + def test_parse_candidate_profile_no_frontmatter(self, command): + """Test parse_candidate_profile handles markdown with no frontmatter.""" + content = """# Just a heading + +Some content""" + + profile_text = command.parse_candidate_profile(content) + + assert profile_text == "# Just a heading\n\nSome content" + + def test_parse_candidate_profile_incomplete_frontmatter(self, command): + """Test parse_candidate_profile handles incomplete frontmatter without crashing.""" + content = """--- +name: John Doe +missing closing dashes + +# Statement""" + + profile_text = command.parse_candidate_profile(content) + assert profile_text == content.strip() + + def test_parse_candidate_profile_strips_only_first_frontmatter(self, command): + """Test parse_candidate_profile strips only the leading frontmatter block.""" + content = """--- +name: John Doe +email: john.doe@example.com +--- + +## About Me + +Some bio. + +--- + +## Key Contributions + +I contributed to the board.""" + profile_text = command.parse_candidate_profile(content) + + assert profile_text == ( + "## About Me\n\nSome bio.\n\n---\n\n## Key Contributions\n\n" + "I contributed to the board." + ) + def test_sync_year_candidates_success(self, command, mocker): mocker.patch( "apps.owasp.management.commands.owasp_sync_board_candidates.get_repository_file_content" @@ -93,6 +152,9 @@ def test_sync_year_candidates_success(self, command, mocker): ) mock_update_data = mocker.patch("apps.owasp.models.entity_member.EntityMember.update_data") + mock_profile_update_or_create = mocker.patch( + "apps.owasp.models.board_candidate_profile.BoardCandidateProfile.objects.update_or_create" + ) repo_files = [{"name": "jane-doe.md", "download_url": "https://github.com/jane-doe.md"}] @@ -118,6 +180,52 @@ def side_effect(url): data_arg = args[0] assert kwargs["save"] assert data_arg["member_name"] == "Jane Doe" + mock_profile_update_or_create.assert_called_once_with( + candidate=mock_update_data.return_value, + defaults={"raw_markdown": "Bio"}, + ) + + def test_sync_year_candidates_no_frontmatter(self, command, mocker): + mocker.patch( + "apps.owasp.management.commands.owasp_sync_board_candidates.get_repository_file_content" + ) + + mock_board = Mock() + mock_board.id = 100 + mock_board_manager = Mock() + mock_board_manager.get_or_create.return_value = (mock_board, True) + mocker.patch( + "apps.owasp.models.board_of_directors.BoardOfDirectors.objects", mock_board_manager + ) + + mock_update_data = mocker.patch("apps.owasp.models.entity_member.EntityMember.update_data") + mock_profile_update_or_create = mocker.patch( + "apps.owasp.models.board_candidate_profile.BoardCandidateProfile.objects.update_or_create" + ) + + repo_files = [{"name": "jane-doe.md", "download_url": "https://github.com/jane-doe.md"}] + + file_content = "# Just a heading\n\nBio without frontmatter" + + def side_effect(url): + if "contents/2024" in url: + return json.dumps(repo_files) + if "jane-doe.md" in url: + return file_content + return "" + + mocker.patch( + "apps.owasp.management.commands.owasp_sync_board_candidates.get_repository_file_content", + side_effect=side_effect, + ) + + count = command.sync_year_candidates(2024) + + assert count == 1 + mock_profile_update_or_create.assert_called_once_with( + candidate=mock_update_data.return_value, + defaults={"raw_markdown": file_content}, + ) def test_sync_year_candidates_api_error(self, command, mocker): mocker.patch( diff --git a/backend/tests/unit/apps/owasp/models/board_candidate_claim_test.py b/backend/tests/unit/apps/owasp/models/board_candidate_claim_test.py index 6b17e35d02..0380a4ca67 100644 --- a/backend/tests/unit/apps/owasp/models/board_candidate_claim_test.py +++ b/backend/tests/unit/apps/owasp/models/board_candidate_claim_test.py @@ -111,6 +111,13 @@ def test_description_default_empty(self): assert field.default == "" + def test_source_text_default_empty(self): + """Test source_text field defaults to empty string.""" + field = BoardCandidateClaim._meta.get_field("source_text") + + assert field.default == "" + assert field.blank is True + def test_clean_new_claim_passes(self): """Test that clean passes for new draft claims without pk.""" claim = BoardCandidateClaim(name="New Claim", status=BoardCandidateClaim.Status.DRAFT) diff --git a/backend/tests/unit/apps/owasp/models/board_candidate_profile_test.py b/backend/tests/unit/apps/owasp/models/board_candidate_profile_test.py new file mode 100644 index 0000000000..61b16c9029 --- /dev/null +++ b/backend/tests/unit/apps/owasp/models/board_candidate_profile_test.py @@ -0,0 +1,31 @@ +"""Tests for BoardCandidateProfile model.""" + +from apps.owasp.models.board_candidate_profile import BoardCandidateProfile +from apps.owasp.models.entity_member import EntityMember + + +class TestBoardCandidateProfileModel: + """Tests for BoardCandidateProfile model.""" + + def test_str_representation(self) -> None: + """Test __str__ returns the correct representation.""" + candidate = EntityMember(member_name="Jane Doe") + profile = BoardCandidateProfile(candidate=candidate) + + assert str(profile) == "Profile for Jane Doe" + + def test_meta_options(self) -> None: + """Test model meta options.""" + assert BoardCandidateProfile._meta.db_table == "owasp_board_candidate_profile" + assert BoardCandidateProfile._meta.verbose_name_plural == "Board Candidate Profiles" + + def test_has_timestamp_fields(self) -> None: + """Test model has timestamp fields from TimestampedModel.""" + assert hasattr(BoardCandidateProfile, "nest_created_at") + assert hasattr(BoardCandidateProfile, "nest_updated_at") + + def test_raw_markdown_default_empty(self) -> None: + """Test raw_markdown field defaults to empty string.""" + field = BoardCandidateProfile._meta.get_field("raw_markdown") + assert field.default == "" + assert field.blank is True From 2a7a6380ea3fe89dc11cdb6c745d3d4c1e88c470 Mon Sep 17 00:00:00 2001 From: Rudransh Shrivastava Date: Wed, 5 Aug 2026 19:45:42 +0530 Subject: [PATCH 02/30] add candidate profile page with annotations add AnnotatedProfile component to handle annotations. update single claim and evidence pages to show approved/rejected claims publicly. add source_url to queries and mutations but next commit will wire it up with create/update pages. Signed-off-by: Rudransh Shrivastava --- .../unit/components/AnnotatedProfile.test.tsx | 404 ++++++++++++++++++ .../unit/pages/BoardCandidatesPage.test.tsx | 1 + .../unit/pages/ClaimDetailsPage.test.tsx | 149 +++++++ .../unit/pages/EvidenceDetailsPage.test.tsx | 99 +++++ .../evidences/[evidenceKey]/page.tsx | 17 +- .../[login]/claims/[claimKey]/page.tsx | 32 +- .../board/[year]/candidates/[login]/page.tsx | 71 +++ .../src/app/board/[year]/candidates/page.tsx | 15 +- frontend/src/app/globals.css | 42 ++ frontend/src/components/AnnotatedProfile.tsx | 306 +++++++++++++ .../src/server/mutations/claimMutations.ts | 2 + frontend/src/server/queries/boardQueries.ts | 28 ++ frontend/src/server/queries/claimQueries.ts | 3 + .../__generated__/boardQueries.generated.ts | 10 + .../__generated__/claimMutations.generated.ts | 8 +- .../__generated__/claimQueries.generated.ts | 12 +- frontend/src/types/__generated__/graphql.ts | 20 + 17 files changed, 1189 insertions(+), 30 deletions(-) create mode 100644 frontend/__tests__/unit/components/AnnotatedProfile.test.tsx create mode 100644 frontend/src/app/board/[year]/candidates/[login]/page.tsx create mode 100644 frontend/src/components/AnnotatedProfile.tsx diff --git a/frontend/__tests__/unit/components/AnnotatedProfile.test.tsx b/frontend/__tests__/unit/components/AnnotatedProfile.test.tsx new file mode 100644 index 0000000000..77faa4751d --- /dev/null +++ b/frontend/__tests__/unit/components/AnnotatedProfile.test.tsx @@ -0,0 +1,404 @@ +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { useRouter } from 'next/navigation' +import { ClaimStatusEnum } from 'types/__generated__/graphql' +import AnnotatedProfile, { + escapeAttr, + injectHighlights, + normalizeIndentedHtml, + renderMarkdown, + resolveMediaUrls, + visibleStatuses, +} from 'components/AnnotatedProfile' +import type { VisibleClaim } from 'components/AnnotatedProfile' + +jest.mock('dompurify', () => ({ + sanitize: (html: string) => html, +})) + +jest.mock('markdown-it', () => { + const markdownIt = jest.requireActual('markdown-it') + return { __esModule: true, default: markdownIt } +}) + +const claim = (overrides: Partial = {}): VisibleClaim => ({ + id: 'claim-1', + key: 'claim-1', + name: 'Claim One', + sourceText: 'OWASP projects', + status: ClaimStatusEnum.Approved, + ...overrides, +}) + +describe('visibleStatuses', () => { + it('returns approved and rejected for public viewers', () => { + expect(visibleStatuses(false, false)).toEqual([ + ClaimStatusEnum.Approved, + ClaimStatusEnum.Rejected, + ]) + }) + + it('adds submitted for reviewers', () => { + expect(visibleStatuses(false, true)).toEqual([ + ClaimStatusEnum.Approved, + ClaimStatusEnum.Rejected, + ClaimStatusEnum.Submitted, + ]) + }) + + it('adds submitted and draft for candidates', () => { + expect(visibleStatuses(true, false)).toEqual([ + ClaimStatusEnum.Approved, + ClaimStatusEnum.Rejected, + ClaimStatusEnum.Submitted, + ClaimStatusEnum.Draft, + ]) + }) + + it('never includes withdrawn', () => { + for (const [isCandidate, isReviewer] of [ + [false, false], + [false, true], + [true, false], + [true, true], + ]) { + expect(visibleStatuses(isCandidate, isReviewer)).not.toContain(ClaimStatusEnum.Withdrawn) + } + }) +}) + +describe('escapeAttr', () => { + it('escapes ampersands, quotes, and angle brackets', () => { + expect(escapeAttr('a&b"ce')).toBe('a&b"c<d>e') + }) + + it('leaves plain text unchanged', () => { + expect(escapeAttr('plain text')).toBe('plain text') + }) +}) + +describe('resolveMediaUrls', () => { + it('resolves a relative src against the board candidates page base', () => { + const html = 'Arkadii Yakovets' + expect(resolveMediaUrls(html, '2025')).toBe( + 'Arkadii Yakovets' + ) + }) + + it('resolves every media tag with a src attribute', () => { + const html = '' + const resolved = resolveMediaUrls(html, '2025') + expect(resolved).toContain('https://owasp.org/www-board-candidates/assets/images/a.png') + expect(resolved).toContain('https://owasp.org/www-board-candidates/2025/b.mp4') + }) + + it('leaves absolute external URLs untouched', () => { + const html = '' + expect(resolveMediaUrls(html, '2025')).toBe(html) + }) + + it('leaves invalid src values untouched', () => { + const html = '' + expect(resolveMediaUrls(html, '2025')).toBe(html) + }) +}) + +describe('renderMarkdown', () => { + it('splits paragraphs on blank lines', () => { + expect(renderMarkdown('one\n\ntwo', [], '2025')).toBe('

one

\n

two

\n') + }) + + it('renders headings and emphasis', () => { + expect(renderMarkdown('### Title\n\n**bold** text', [], '2025')).toBe( + '

Title

\n

bold text

\n' + ) + }) + + it('renders markdown links', () => { + expect(renderMarkdown('[OWASP](https://owasp.org)', [], '2025')).toBe( + '

OWASP

\n' + ) + }) + + it('resolves relative image paths to the board candidates site', () => { + expect(renderMarkdown('photo', [], '2025')).toBe( + 'photo' + ) + }) + + it('renders indented block-level html as markup instead of a code block', () => { + const markdown = ['
', ' Global Engagement', '
'].join('\n') + const result = renderMarkdown(markdown, [], '2025') + expect(result).not.toContain('
')
+    expect(result).toContain('
') + }) + + it('wraps matching source text in a mark tag', () => { + const result = renderMarkdown('I support OWASP projects and more.', [claim()], '2025') + expect(result).toContain(' { + it('de-indents only lines that start with a tag', () => { + const markdown = ['
', ' nested content', '
', ' plain text'].join( + '\n' + ) + expect(normalizeIndentedHtml(markdown)).toBe( + ['
', ' nested content', '
', ' plain text'].join('\n') + ) + }) + + it('leaves genuine indented code blocks untouched', () => { + const markdown = ['Before.', '', ' def hello():', ' print("hi")', '', 'After.'].join( + '\n' + ) + expect(normalizeIndentedHtml(markdown)).toBe(markdown) + }) + + it('does not de-indent lines indented more than 4 spaces', () => { + expect(normalizeIndentedHtml(' deep')).toBe(' deep') + }) + + it('leaves unindented content and empty strings unchanged', () => { + expect(normalizeIndentedHtml('
\n

Hello

\n
')).toBe( + '
\n

Hello

\n
' + ) + expect(normalizeIndentedHtml('')).toBe('') + }) +}) + +describe('injectHighlights', () => { + it('returns the markdown unchanged when there are no claims', () => { + const markdown = 'Hi OWASP Community!' + expect(injectHighlights(markdown, [])).toBe(markdown) + }) + + it('returns the markdown unchanged when no source text matches', () => { + const markdown = 'Hi OWASP Community!' + expect(injectHighlights(markdown, [claim({ sourceText: 'no match here' })])).toBe(markdown) + }) + + it('wraps a matching occurrence in a mark tag with dataset attributes', () => { + const markdown = 'I support OWASP projects and more.' + expect(injectHighlights(markdown, [claim()])).toBe( + 'I support OWASP projects and more.' + ) + }) + + it('preserves text before, between, and after highlights', () => { + const markdown = 'aaa OWASP projects bbb OWASP projects ccc' + const result = injectHighlights(markdown, [claim()]) + expect(result.startsWith('aaa ')).toBe(true) + expect(result.endsWith(' ccc')).toBe(true) + expect(result.match(/ { + const markdown = 'OWASP projects are great. OWASP projects matter.' + expect(injectHighlights(markdown, [claim()]).match(/ { + const markdown = 'OWASP projects are open source.' + const draft = claim({ key: 'draft', name: 'Draft', status: ClaimStatusEnum.Draft }) + const approved = claim({ key: 'approved', name: 'Approved', status: ClaimStatusEnum.Approved }) + const result = injectHighlights(markdown, [draft, approved]) + expect(result).toContain('data-claim-key="approved"') + expect(result).not.toContain('data-claim-key="draft"') + }) + + it('does not create partially overlapping partial marks', () => { + const markdown = 'OWASP projects are great.' + const first = claim({ key: 'first', sourceText: 'OWASP proj' }) + const second = claim({ key: 'second', sourceText: 'projects are' }) + expect(injectHighlights(markdown, [first, second]).match(/ { + const markdown = 'The officer leads the squad.' + const draft = claim({ + key: 'draft', + name: 'Draft', + sourceText: 'officer', + status: ClaimStatusEnum.Draft, + }) + const approved = claim({ + key: 'approved', + name: 'Approved', + sourceText: 'officer leads', + }) + const result = injectHighlights(markdown, [draft, approved]) + expect(result).toContain('data-claim-key="draft"') + expect(result).not.toContain('data-claim-key="approved"') + expect(result.match(/ { + const markdown = 'OWASP projects and leadership' + const draft = claim({ + key: 'draft', + sourceText: 'OWASP projects', + status: ClaimStatusEnum.Draft, + }) + const approved = claim({ key: 'approved', sourceText: 'leadership' }) + const result = injectHighlights(markdown, [draft, approved]) + expect(result).toContain('data-claim-key="draft"') + expect(result).toContain('data-claim-key="approved"') + }) + + it('skips an inner range fully contained in a previously added one', () => { + const markdown = 'OWASP projects matter.' + const outer = claim({ key: 'outer', sourceText: 'OWASP projects' }) + const inner = claim({ key: 'inner', sourceText: 'projects' }) + const result = injectHighlights(markdown, [outer, inner]) + expect(result.match(/ { + const special = claim({ key: 'a&b"c', name: 'Name with "quotes"' }) + const result = injectHighlights('OWASP projects', [special]) + expect(result).toContain('data-claim-key="a&b"c"') + expect(result).toContain('data-claim-name="Name with "quotes""') + }) + + it('ignores empty source text', () => { + expect(injectHighlights('plain text content', [claim({ sourceText: '' })])).toBe( + 'plain text content' + ) + }) +}) + +describe('AnnotatedProfile component', () => { + const mockPush = jest.fn() + + const renderProfile = ( + props: { + claims?: VisibleClaim[] + isCandidate?: boolean + isReviewer?: boolean + login?: string + rawMarkdown?: string + year?: string + } = {} + ) => + render( + + ) + + beforeEach(() => { + jest.useFakeTimers() + mockPush.mockClear() + ;(useRouter as jest.Mock).mockReturnValue({ push: mockPush }) + }) + + afterEach(() => { + cleanup() + jest.useRealTimers() + }) + + it('renders the raw markdown as HTML', () => { + renderProfile({ rawMarkdown: 'Hello **bold** world' }) + expect(screen.getByText(/Hello/)).toBeInTheDocument() + expect(screen.getByText('bold')).toBeInTheDocument() + }) + + it('renders markdown content when no claims exist', () => { + const { container } = renderProfile({ rawMarkdown: 'Hi OWASP Community!' }) + expect(container.textContent).toContain('Hi OWASP Community!') + }) + + it('renders visible claims as mark elements with dataset attributes', () => { + const { container } = renderProfile({ + claims: [claim()], + rawMarkdown: 'OWASP projects are great.', + }) + const mark = container.querySelector('mark[data-claim-key="claim-1"]') + expect(mark).not.toBeNull() + expect(mark?.getAttribute('data-claim-name')).toBe('Claim One') + expect(mark?.getAttribute('data-claim-status')).toBe('APPROVED') + }) + + it('filters out claims whose status is not visible', () => { + const { container } = renderProfile({ + claims: [claim({ status: ClaimStatusEnum.Withdrawn })], + rawMarkdown: 'OWASP projects are great.', + }) + expect(container.querySelector('mark[data-claim-key]')).toBeNull() + }) + + it('does not show the tooltip until a highlight is hovered', () => { + const { container } = renderProfile({ claims: [claim()], rawMarkdown: 'OWASP projects.' }) + expect(container.querySelector('[data-tooltip]')).toBeNull() + }) + + it('shows the tooltip when hovering a highlight', () => { + const { container } = renderProfile({ claims: [claim()], rawMarkdown: 'OWASP projects.' }) + const mark = container.querySelector('mark[data-claim-key]') + expect(mark).not.toBeNull() + fireEvent.mouseOver(mark as Element) + expect(screen.getByText('Claim One')).toBeInTheDocument() + expect(screen.getByText('Approved')).toBeInTheDocument() + }) + + it('keeps the tooltip open while hovering it', () => { + const { container } = renderProfile({ claims: [claim()], rawMarkdown: 'OWASP projects.' }) + const mark = container.querySelector('mark[data-claim-key]') + fireEvent.mouseOver(mark as Element) + const tooltip = container.querySelector('[data-tooltip]') + expect(tooltip).not.toBeNull() + fireEvent.mouseOver(tooltip as Element) + act(() => { + jest.advanceTimersByTime(500) + }) + expect(container.querySelector('[data-tooltip]')).not.toBeNull() + }) + + it('hides the tooltip when the mouse leaves the profile', () => { + const { container } = renderProfile({ claims: [claim()], rawMarkdown: 'OWASP projects.' }) + const mark = container.querySelector('mark[data-claim-key]') + fireEvent.mouseOver(mark as Element) + expect(screen.getByText('Claim One')).toBeInTheDocument() + fireEvent.mouseLeave(container.querySelector('.relative') as Element) + act(() => { + jest.advanceTimersByTime(500) + }) + expect(container.querySelector('[data-tooltip]')).toBeNull() + }) + + it('hides the tooltip when the page is scrolled', () => { + const { container } = renderProfile({ claims: [claim()], rawMarkdown: 'OWASP projects.' }) + const mark = container.querySelector('mark[data-claim-key]') + fireEvent.mouseOver(mark as Element) + expect(screen.getByText('Claim One')).toBeInTheDocument() + fireEvent.scroll(window) + expect(container.querySelector('[data-tooltip]')).toBeNull() + }) + + it('navigates to the claim page when the tooltip is clicked', () => { + const { container } = renderProfile({ + claims: [claim()], + login: 'arkid15r', + rawMarkdown: 'OWASP projects.', + year: '2025', + }) + const mark = container.querySelector('mark[data-claim-key]') + fireEvent.mouseOver(mark as Element) + const button = screen.getByRole('button') + fireEvent.click(button) + expect(mockPush).toHaveBeenCalledWith('/board/2025/candidates/arkid15r/claims/claim-1') + expect(container.querySelector('[data-tooltip]')).toBeNull() + }) +}) diff --git a/frontend/__tests__/unit/pages/BoardCandidatesPage.test.tsx b/frontend/__tests__/unit/pages/BoardCandidatesPage.test.tsx index b72d696b55..61e233b7ce 100644 --- a/frontend/__tests__/unit/pages/BoardCandidatesPage.test.tsx +++ b/frontend/__tests__/unit/pages/BoardCandidatesPage.test.tsx @@ -15,6 +15,7 @@ jest.mock('@apollo/client/react', () => ({ jest.mock('next/navigation', () => ({ useParams: jest.fn(() => ({ year: '2025' })), + useRouter: jest.fn(() => ({ push: jest.fn() })), })) jest.mock('app/global-error', () => ({ diff --git a/frontend/__tests__/unit/pages/ClaimDetailsPage.test.tsx b/frontend/__tests__/unit/pages/ClaimDetailsPage.test.tsx index 46556d9ed9..e83b615e28 100644 --- a/frontend/__tests__/unit/pages/ClaimDetailsPage.test.tsx +++ b/frontend/__tests__/unit/pages/ClaimDetailsPage.test.tsx @@ -147,4 +147,153 @@ describe('ClaimDetailsPage', () => { expect(screen.getByTestId('claim-actions')).toBeInTheDocument() }) }) + + test('renders approved claim for non-owner, non-reviewer', async () => { + mockUseDjangoSession.mockReturnValue({ + isSyncing: false, + session: { user: { login: 'otheruser' } }, + status: 'authenticated', + }) + mockUseQuery.mockReturnValue({ + data: { + boardCandidateClaim: { ...mockSingleClaim, status: 'APPROVED' }, + boardCandidateClaimEvidences: mockEvidences, + }, + loading: false, + error: null, + }) + + render() + + await waitFor(() => { + expect(screen.getByText(/Leadership Experience/i)).toBeInTheDocument() + }) + }) + + test('renders rejected claim for non-owner, non-reviewer', async () => { + mockUseDjangoSession.mockReturnValue({ + isSyncing: false, + session: { user: { login: 'otheruser' } }, + status: 'authenticated', + }) + mockUseQuery.mockReturnValue({ + data: { + boardCandidateClaim: { ...mockSingleClaim, status: 'REJECTED' }, + boardCandidateClaimEvidences: mockEvidences, + }, + loading: false, + error: null, + }) + + render() + + await waitFor(() => { + expect(screen.getByText(/Leadership Experience/i)).toBeInTheDocument() + }) + }) + + test('renders approved claim for anonymous user', async () => { + mockUseDjangoSession.mockReturnValue({ + isSyncing: false, + session: null, + status: 'unauthenticated', + }) + mockUseQuery.mockReturnValue({ + data: { + boardCandidateClaim: { ...mockSingleClaim, status: 'APPROVED' }, + boardCandidateClaimEvidences: mockEvidences, + }, + loading: false, + error: null, + }) + + render() + + await waitFor(() => { + expect(screen.getByText(/Leadership Experience/i)).toBeInTheDocument() + }) + }) + + test('denies draft claim for non-owner, non-reviewer', () => { + mockUseDjangoSession.mockReturnValue({ + isSyncing: false, + session: { user: { login: 'otheruser' } }, + status: 'authenticated', + }) + mockUseQuery.mockReturnValue({ + data: { + boardCandidateClaim: { ...mockSingleClaim, status: 'DRAFT' }, + boardCandidateClaimEvidences: mockEvidences, + }, + loading: false, + error: null, + }) + + render() + + expect(screen.getByText('Access Denied')).toBeInTheDocument() + }) + + test('denies submitted claim for non-owner, non-reviewer', () => { + mockUseDjangoSession.mockReturnValue({ + isSyncing: false, + session: { user: { login: 'otheruser' } }, + status: 'authenticated', + }) + mockUseQuery.mockReturnValue({ + data: { + boardCandidateClaim: { ...mockSingleClaim, status: 'SUBMITTED' }, + boardCandidateClaimEvidences: mockEvidences, + }, + loading: false, + error: null, + }) + + render() + + expect(screen.getByText('Access Denied')).toBeInTheDocument() + }) + + test('denies draft claim for anonymous user', () => { + mockUseDjangoSession.mockReturnValue({ + isSyncing: false, + session: null, + status: 'unauthenticated', + }) + mockUseQuery.mockReturnValue({ + data: { + boardCandidateClaim: { ...mockSingleClaim, status: 'DRAFT' }, + boardCandidateClaimEvidences: mockEvidences, + }, + loading: false, + error: null, + }) + + render() + + expect(screen.getByText('Access Denied')).toBeInTheDocument() + }) + + test('does not render ClaimActions for non-owner, non-reviewer public viewer', async () => { + mockUseDjangoSession.mockReturnValue({ + isSyncing: false, + session: { user: { login: 'otheruser' } }, + status: 'authenticated', + }) + mockUseQuery.mockReturnValue({ + data: { + boardCandidateClaim: { ...mockSingleClaim, status: 'APPROVED' }, + boardCandidateClaimEvidences: mockEvidences, + }, + loading: false, + error: null, + }) + + render() + + await waitFor(() => { + expect(screen.getByText(/Leadership Experience/i)).toBeInTheDocument() + }) + expect(screen.queryByTestId('claim-actions')).not.toBeInTheDocument() + }) }) diff --git a/frontend/__tests__/unit/pages/EvidenceDetailsPage.test.tsx b/frontend/__tests__/unit/pages/EvidenceDetailsPage.test.tsx index fd4c250a99..3dbfd90ab5 100644 --- a/frontend/__tests__/unit/pages/EvidenceDetailsPage.test.tsx +++ b/frontend/__tests__/unit/pages/EvidenceDetailsPage.test.tsx @@ -210,6 +210,105 @@ describe('EvidenceDetailsPage', () => { }) }) + test('renders approved claim evidence for non-owner, non-reviewer', async () => { + mockUseDjangoSession.mockReturnValue({ + isSyncing: false, + session: { user: { login: 'otheruser' } }, + status: 'authenticated', + }) + mockUseQuery.mockReturnValue({ + data: { + boardCandidateClaim: { ...stableData.boardCandidateClaim, status: 'APPROVED' }, + boardCandidateClaimEvidences: stableData.boardCandidateClaimEvidences, + }, + loading: false, + error: null, + }) + + render() + + await waitFor(() => { + expect(screen.getAllByText(/Certificate/i).length).toBeGreaterThanOrEqual(2) + }) + }) + + test('renders rejected claim evidence for anonymous user', async () => { + mockUseDjangoSession.mockReturnValue({ + isSyncing: false, + session: null, + status: 'unauthenticated', + }) + mockUseQuery.mockReturnValue({ + data: { + boardCandidateClaim: { ...stableData.boardCandidateClaim, status: 'REJECTED' }, + boardCandidateClaimEvidences: stableData.boardCandidateClaimEvidences, + }, + loading: false, + error: null, + }) + + render() + + await waitFor(() => { + expect(screen.getAllByText(/Certificate/i).length).toBeGreaterThanOrEqual(2) + }) + }) + + test('denies draft claim evidence for non-owner, non-reviewer', () => { + mockUseDjangoSession.mockReturnValue({ + isSyncing: false, + session: { user: { login: 'otheruser' } }, + status: 'authenticated', + }) + + render() + + expect(screen.getByText('Access Denied')).toBeInTheDocument() + }) + + test('denies submitted claim evidence for non-owner, non-reviewer', () => { + mockUseDjangoSession.mockReturnValue({ + isSyncing: false, + session: { user: { login: 'otheruser' } }, + status: 'authenticated', + }) + mockUseQuery.mockReturnValue({ + data: { + boardCandidateClaim: { ...stableData.boardCandidateClaim, status: 'SUBMITTED' }, + boardCandidateClaimEvidences: stableData.boardCandidateClaimEvidences, + }, + loading: false, + error: null, + }) + + render() + + expect(screen.getByText('Access Denied')).toBeInTheDocument() + }) + + test('does not render EvidenceActions for public viewer', async () => { + mockUseDjangoSession.mockReturnValue({ + isSyncing: false, + session: { user: { login: 'otheruser' } }, + status: 'authenticated', + }) + mockUseQuery.mockReturnValue({ + data: { + boardCandidateClaim: { ...stableData.boardCandidateClaim, status: 'APPROVED' }, + boardCandidateClaimEvidences: stableData.boardCandidateClaimEvidences, + }, + loading: false, + error: null, + }) + + render() + + await waitFor(() => { + expect(screen.getAllByText(/Certificate/i).length).toBeGreaterThanOrEqual(2) + }) + expect(screen.queryByTestId('evidence-actions')).not.toBeInTheDocument() + }) + test('renders 500 error display on query error', async () => { mockUseQuery.mockReturnValue({ data: null, diff --git a/frontend/src/app/board/[year]/candidates/[login]/claims/[claimKey]/evidences/[evidenceKey]/page.tsx b/frontend/src/app/board/[year]/candidates/[login]/claims/[claimKey]/evidences/[evidenceKey]/page.tsx index fd302b5e8d..c6def59aa2 100644 --- a/frontend/src/app/board/[year]/candidates/[login]/claims/[claimKey]/evidences/[evidenceKey]/page.tsx +++ b/frontend/src/app/board/[year]/candidates/[login]/claims/[claimKey]/evidences/[evidenceKey]/page.tsx @@ -11,6 +11,7 @@ import { FaDownload } from 'react-icons/fa6' import { ErrorDisplay, handleAppError } from 'app/global-error' import { GetClaimAndEvidencesDocument } from 'types/__generated__/claimQueries.generated' import { GetBoardCandidateClaimEvidenceFileUrlDocument } from 'types/__generated__/evidenceQueries.generated' +import { ClaimStatusEnum } from 'types/__generated__/graphql' import { titleCaseWord } from 'utils/capitalize' import { formatDate } from 'utils/dateFormatter' import AccessDeniedDisplay from 'components/AccessDeniedDisplay' @@ -30,7 +31,7 @@ const EvidenceDetailsPage = () => { const { isSyncing, session } = useDjangoSession() const { data, loading, error } = useQuery(GetClaimAndEvidencesDocument, { fetchPolicy: 'cache-and-network', - skip: isSyncing || !claimKey || !login || !year || !session?.user?.login, + skip: isSyncing || !claimKey || !login || !year, variables: { key: claimKey, login, @@ -40,6 +41,7 @@ const EvidenceDetailsPage = () => { }) const isReviewer = data?.boardOfDirectors?.reviewer != null + const isOwner = session?.user?.login === login const [fetchFileUrl] = useLazyQuery(GetBoardCandidateClaimEvidenceFileUrlDocument) const claim = data?.boardCandidateClaim @@ -54,9 +56,16 @@ const EvidenceDetailsPage = () => { if (loading || isSyncing) return - if (session?.user?.login !== login && !isReviewer) { + const publicClaimStatuses = [ClaimStatusEnum.Approved, ClaimStatusEnum.Rejected] + const canView = + isOwner || isReviewer || (claim?.status != null && publicClaimStatuses.includes(claim.status)) + + if (!canView) { return ( - + ) } @@ -135,7 +144,7 @@ const EvidenceDetailsPage = () => { {'Download Evidence'} )} - {!isReviewer && ( + {isOwner && ( { error: graphQLRequestError, } = useQuery(GetClaimAndEvidencesDocument, { fetchPolicy: 'cache-and-network', - skip: isSyncing || !claimKey || !year || !session?.user?.login, + skip: isSyncing || !claimKey || !year, variables: { key: claimKey, login, @@ -43,11 +43,16 @@ const ClaimDetailsPage = () => { }) const isReviewer = graphQLData?.boardOfDirectors?.reviewer != null + const isOwner = session?.user?.login === login const claim = graphQLData?.boardCandidateClaim const evidences = graphQLData?.boardCandidateClaimEvidences ?? [] const hasReviewed = claim?.reviews?.some((r) => r.reviewer?.login === session?.user?.login) ?? false + const publicClaimStatuses = [ClaimStatusEnum.Approved, ClaimStatusEnum.Rejected] + const canView = + isOwner || isReviewer || (claim?.status != null && publicClaimStatuses.includes(claim.status)) + useEffect(() => { if (graphQLRequestError) { handleAppError(graphQLRequestError) @@ -65,9 +70,12 @@ const ClaimDetailsPage = () => { if (isLoading || isSyncing) return - if (session?.user?.login !== login && !isReviewer) { + if (!canView) { return ( - + ) } @@ -113,19 +121,21 @@ const ClaimDetailsPage = () => {

@{login}

- {claim.status === ClaimStatusEnum.Draft && session?.user?.login === login && ( + {claim.status === ClaimStatusEnum.Draft && isOwner && ( {'Add Evidence'} )} - + {(isOwner || isReviewer) && ( + + )}
diff --git a/frontend/src/app/board/[year]/candidates/[login]/page.tsx b/frontend/src/app/board/[year]/candidates/[login]/page.tsx new file mode 100644 index 0000000000..843540dbe9 --- /dev/null +++ b/frontend/src/app/board/[year]/candidates/[login]/page.tsx @@ -0,0 +1,71 @@ +'use client' + +import { useQuery } from '@apollo/client/react' +import { useDjangoSession } from 'hooks/useDjangoSession' +import { useParams } from 'next/navigation' +import { useEffect } from 'react' +import { ErrorDisplay, handleAppError } from 'app/global-error' +import { GetCandidateProfileDocument } from 'types/__generated__/boardQueries.generated' +import AnnotatedProfile from 'components/AnnotatedProfile' +import PageWrapper from 'components/cards/PageWrapper' +import LoadingSpinner from 'components/LoadingSpinner' + +const CandidateProfilePage = () => { + const { login, year } = useParams<{ login: string; year: string }>() + const { isSyncing, session } = useDjangoSession() + + const { data, error, loading } = useQuery(GetCandidateProfileDocument, { + skip: isSyncing, + variables: { + login, + sessionLogin: session?.user?.login ?? '', + year: Number.parseInt(year), + }, + }) + + useEffect(() => { + if (error) { + handleAppError(error) + } + }, [error]) + + if (isSyncing || loading) { + return + } + + const claims = data?.boardCandidateClaims ?? [] + const isCandidate = data?.boardOfDirectors?.candidate != null && session?.user?.login === login + const isReviewer = data?.boardOfDirectors?.reviewer != null + const profile = data?.boardCandidateProfile + + if (!profile) { + return ( + + ) + } + + return ( + +
+

+ {profile.candidate.memberName} +

+

{year} Board Candidate

+
+ +
+ ) +} + +export default CandidateProfilePage diff --git a/frontend/src/app/board/[year]/candidates/page.tsx b/frontend/src/app/board/[year]/candidates/page.tsx index 5651a906d8..a268e73b35 100644 --- a/frontend/src/app/board/[year]/candidates/page.tsx +++ b/frontend/src/app/board/[year]/candidates/page.tsx @@ -7,7 +7,7 @@ import { useDjangoSession } from 'hooks/useDjangoSession' import millify from 'millify' import Image from 'next/image' import Link from 'next/link' -import { useParams } from 'next/navigation' +import { useParams, useRouter } from 'next/navigation' import { useEffect, useState } from 'react' import { FaCode, FaExclamationCircle } from 'react-icons/fa' import { FaLinkedin, FaCodeBranch, FaCodeMerge, FaPenToSquare } from 'react-icons/fa6' @@ -94,6 +94,7 @@ interface CandidateCardProps { const CandidateCard = ({ candidate, isOwnProfile, year }: CandidateCardProps) => { const client = useApolloClient() + const router = useRouter() const [snapshot, setSnapshot] = useState(null) const [ledChapters, setLedChapters] = useState([]) const [ledProjects, setLedProjects] = useState([]) @@ -281,10 +282,14 @@ const CandidateCard = ({ candidate, isOwnProfile, year }: CandidateCardProps) => }, [client, snapshot?.projectContributions]) const handleCardClick = () => { - // Convert name to slug format. - const nameSlug = candidate.memberName.toLowerCase().replaceAll(/\s+/g, '_') - const candidateUrl = `https://owasp.org/www-board-candidates/${year}/${nameSlug}.html` - window.open(candidateUrl, '_blank', 'noopener,noreferrer') + if (candidate.member?.login) { + router.push(`/board/${year}/candidates/${candidate.member.login}`) + } else { + // Convert name to slug format. + const nameSlug = candidate.memberName.toLowerCase().replaceAll(/\s+/g, '_') + const candidateUrl = `https://owasp.org/www-board-candidates/${year}/${nameSlug}.html` + window.open(candidateUrl, '_blank', 'noopener,noreferrer') + } } // Check if candidate leads any flagship level projects diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index f2115fc706..7c4b966e12 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -352,6 +352,48 @@ select:disabled, padding-left: 1.5em; } + .md-wrapper p { + margin: 0 0 1em; + } + + .md-wrapper h1, + .md-wrapper h2, + .md-wrapper h3, + .md-wrapper h4, + .md-wrapper h5, + .md-wrapper h6 { + margin: 1.5em 0 0.5em; + font-weight: bold; + } + + .md-wrapper img { + display: inline; + max-width: 100%; + height: auto; + } + + .md-wrapper blockquote { + margin: 0 0 1em; + padding-left: 1em; + border-left: 4px solid rgb(156 163 175 / 0.5); + } + + .md-wrapper pre { + margin: 0 0 1em; + overflow-x: auto; + } + + .md-wrapper table { + width: 100%; + margin: 0 0 1em; + } + + .md-wrapper td, + .md-wrapper th { + padding: 0.25em 0.5em; + text-align: left; + } + .md-wrapper ul { list-style-type: disc; } diff --git a/frontend/src/components/AnnotatedProfile.tsx b/frontend/src/components/AnnotatedProfile.tsx new file mode 100644 index 0000000000..1bf65e0da6 --- /dev/null +++ b/frontend/src/components/AnnotatedProfile.tsx @@ -0,0 +1,306 @@ +'use client' + +import DOMPurify from 'dompurify' +import { upperFirst, toLower } from 'lodash' +import markdownit from 'markdown-it' +import { useRouter } from 'next/navigation' +import { useEffect, useMemo, useRef, useState } from 'react' +import { FaArrowRight } from 'react-icons/fa6' + +import { ClaimStatusEnum } from 'types/__generated__/graphql' + +type VisibleClaim = { + id: string + key: string + name: string + sourceText: string + status: ClaimStatusEnum +} + +export type { VisibleClaim } + +interface AnnotatedProfileProps { + claims: VisibleClaim[] + isCandidate: boolean + isReviewer: boolean + login: string + rawMarkdown: string + year: string +} + +export const PRIORITY_ORDER = [ + ClaimStatusEnum.Draft, + ClaimStatusEnum.Submitted, + ClaimStatusEnum.Rejected, + ClaimStatusEnum.Approved, +] as const + +export function visibleStatuses(isCandidate: boolean, isReviewer: boolean): ClaimStatusEnum[] { + return [ + ClaimStatusEnum.Approved, + ClaimStatusEnum.Rejected, + ...(isCandidate || isReviewer ? [ClaimStatusEnum.Submitted] : []), + ...(isCandidate ? [ClaimStatusEnum.Draft] : []), + ] +} + +export const STATUS_COLOR: Record = { + [ClaimStatusEnum.Approved]: 'bg-green-200 text-green-950', + [ClaimStatusEnum.Discarded]: 'bg-gray-200 text-gray-950', + [ClaimStatusEnum.Draft]: 'bg-gray-200 text-gray-950', + [ClaimStatusEnum.Rejected]: 'bg-red-200 text-red-950', + [ClaimStatusEnum.Submitted]: 'bg-amber-200 text-amber-950', + [ClaimStatusEnum.Withdrawn]: 'bg-gray-200 text-gray-950', +} + +const STATUS_DOT: Record = { + [ClaimStatusEnum.Approved]: 'bg-green-400', + [ClaimStatusEnum.Discarded]: 'bg-gray-400', + [ClaimStatusEnum.Draft]: 'bg-gray-400', + [ClaimStatusEnum.Rejected]: 'bg-red-400', + [ClaimStatusEnum.Submitted]: 'bg-amber-400', + [ClaimStatusEnum.Withdrawn]: 'bg-gray-400', +} + +export function escapeAttr(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('"', '"') + .replaceAll('<', '<') + .replaceAll('>', '>') +} + +type HighlightRange = { + start: number + end: number + claim: VisibleClaim +} + +function toHighlightRanges(markdown: string, claim: VisibleClaim): HighlightRange[] { + if (!claim.sourceText) return [] + + const ranges: HighlightRange[] = [] + let searchFrom = 0 + while (searchFrom < markdown.length) { + const start = markdown.indexOf(claim.sourceText, searchFrom) + if (start === -1) break + ranges.push({ start, end: start + claim.sourceText.length, claim }) + searchFrom = start + 1 + } + return ranges +} + +function addHighlightRange(ranges: HighlightRange[], next: HighlightRange): void { + const overlapIndex = ranges.findIndex( + (existing) => existing.start < next.end && existing.end > next.start + ) + if (overlapIndex === -1) { + ranges.push(next) + } else if (ranges[overlapIndex].start === next.start && ranges[overlapIndex].end === next.end) { + ranges[overlapIndex] = next + } +} + +export function injectHighlights(markdown: string, claims: VisibleClaim[]): string { + // Iterate lowest priority first so that a higher-priority claim replaces an + // identical range, while a partially overlapping one is skipped entirely. + const ranges = claims + .filter((c) => (PRIORITY_ORDER as readonly ClaimStatusEnum[]).includes(c.status)) + .sort( + (a, b) => + (PRIORITY_ORDER as readonly ClaimStatusEnum[]).indexOf(a.status) - + (PRIORITY_ORDER as readonly ClaimStatusEnum[]).indexOf(b.status) + ) + .flatMap((claim) => toHighlightRanges(markdown, claim)) + .reduce((acc, range) => { + addHighlightRange(acc, range) + return acc + }, [] as HighlightRange[]) + + ranges.sort((a, b) => a.start - b.start) + + const parts: string[] = [] + let cursor = 0 + for (const { start, end, claim } of ranges) { + if (start > cursor) parts.push(markdown.slice(cursor, start)) + parts.push(renderSegment(markdown.slice(start, end), claim)) + cursor = end + } + if (cursor < markdown.length) parts.push(markdown.slice(cursor)) + return parts.join('') +} + +function renderSegment(text: string, claim: VisibleClaim | null): string { + if (!claim) return text + return ( + `${text}` + ) +} + +export function resolveMediaUrls(html: string, year: string): string { + const baseUrl = `https://owasp.org/www-board-candidates/${year}/` + const doc = new DOMParser().parseFromString(html, 'text/html') + for (const element of Array.from(doc.querySelectorAll('[src]'))) { + try { + element.setAttribute('src', new URL(element.getAttribute('src') ?? '', baseUrl).href) + } catch { + // Leave invalid src values untouched for DOMPurify to handle. + } + } + return doc.body.innerHTML +} + +// CommonMark treats 4+ space indented lines as code. +export function normalizeIndentedHtml(markdown: string): string { + return markdown.replace(/^ {4}(?=<)/gm, '') +} + +export function renderMarkdown(rawMarkdown: string, claims: VisibleClaim[], year: string): string { + const md = markdownit({ + breaks: false, + html: true, + linkify: true, + typographer: true, + }) + const annotated = injectHighlights(normalizeIndentedHtml(rawMarkdown), claims) + return DOMPurify.sanitize(resolveMediaUrls(md.render(annotated), year), { + ADD_ATTR: ['data-claim-key', 'data-claim-name', 'data-claim-status'], + ADD_TAGS: ['mark'], + }) +} + +const AnnotatedProfile = ({ + claims, + isCandidate, + isReviewer, + login, + rawMarkdown, + year, +}: AnnotatedProfileProps) => { + const router = useRouter() + const containerRef = useRef(null) + const hideTimerRef = useRef(null) + const [tooltip, setTooltip] = useState<{ + claimKey: string + claimName: string + claimStatus: string + x: number + y: number + width: number + } | null>(null) + + const filteredClaims = useMemo( + () => claims.filter((c) => visibleStatuses(isCandidate, isReviewer).includes(c.status)), + [claims, isCandidate, isReviewer] + ) + + const html = useMemo( + () => renderMarkdown(rawMarkdown, filteredClaims, year), + [rawMarkdown, filteredClaims, year] + ) + + const scheduleHide = (delay = 400) => { + if (hideTimerRef.current) clearTimeout(hideTimerRef.current) + hideTimerRef.current = window.setTimeout(() => setTooltip(null), delay) + } + + useEffect(() => { + const el = containerRef.current + if (!el) return + + const onMouseOver = (e: MouseEvent) => { + const target = e.target as HTMLElement + if (target.closest('[data-tooltip]')) { + if (hideTimerRef.current) clearTimeout(hideTimerRef.current) + return + } + const mark = target.closest('mark[data-claim-key]') + if (!mark) { + scheduleHide() + return + } + if (hideTimerRef.current) clearTimeout(hideTimerRef.current) + const rect = mark.getBoundingClientRect() + setTooltip({ + claimKey: mark.dataset.claimKey ?? '', + claimName: mark.dataset.claimName ?? '', + claimStatus: mark.dataset.claimStatus ?? '', + x: rect.left, + y: rect.top, + width: rect.width, + }) + } + const onMouseLeave = () => scheduleHide() + const onScroll = () => { + if (hideTimerRef.current) clearTimeout(hideTimerRef.current) + setTooltip(null) + } + + el.addEventListener('mouseover', onMouseOver) + el.addEventListener('mouseleave', onMouseLeave) + window.addEventListener('scroll', onScroll, true) + return () => { + el.removeEventListener('mouseover', onMouseOver) + el.removeEventListener('mouseleave', onMouseLeave) + window.removeEventListener('scroll', onScroll, true) + if (hideTimerRef.current) clearTimeout(hideTimerRef.current) + } + }, [html]) + + const handleTooltipEnter = () => { + if (hideTimerRef.current) clearTimeout(hideTimerRef.current) + } + const handleTooltipLeave = () => scheduleHide() + const handleTooltipClick = () => { + if (!tooltip) return + setTooltip(null) + router.push(`/board/${year}/candidates/${login}/claims/${tooltip.claimKey}`) + } + + return ( +
+
+ {tooltip && ( +
+ +
+
+
+
+ )} +
+ ) +} + +export default AnnotatedProfile diff --git a/frontend/src/server/mutations/claimMutations.ts b/frontend/src/server/mutations/claimMutations.ts index 09a915aa52..eb2818463d 100644 --- a/frontend/src/server/mutations/claimMutations.ts +++ b/frontend/src/server/mutations/claimMutations.ts @@ -14,6 +14,7 @@ export const CREATE_CLAIM = gql` key name order + sourceText status updatedAt } @@ -35,6 +36,7 @@ export const UPDATE_CLAIM = gql` key name order + sourceText status updatedAt } diff --git a/frontend/src/server/queries/boardQueries.ts b/frontend/src/server/queries/boardQueries.ts index 126a19bfb1..5f13759238 100644 --- a/frontend/src/server/queries/boardQueries.ts +++ b/frontend/src/server/queries/boardQueries.ts @@ -39,6 +39,34 @@ export const GET_BOARD_CANDIDATES = gql` } ` +export const GET_CANDIDATE_PROFILE = gql` + query GetCandidateProfile($login: String!, $sessionLogin: String!, $year: Int!) { + boardCandidateProfile(login: $login, year: $year) { + id + rawMarkdown + candidate { + memberName + } + } + boardCandidateClaims(login: $login, year: $year) { + id + key + name + sourceText + status + } + boardOfDirectors(year: $year) { + id + candidate(login: $login) { + id + } + reviewer(login: $sessionLogin) { + id + } + } + } +` + export const GET_MEMBER_SNAPSHOT = gql` query GetMemberSnapshot($userLogin: String!) { memberSnapshot(userLogin: $userLogin) { diff --git a/frontend/src/server/queries/claimQueries.ts b/frontend/src/server/queries/claimQueries.ts index bba661bd73..f491863b92 100644 --- a/frontend/src/server/queries/claimQueries.ts +++ b/frontend/src/server/queries/claimQueries.ts @@ -8,6 +8,7 @@ export const GET_CANDIDATE_CLAIM = gql` description key name + sourceText status updatedAt } @@ -24,6 +25,7 @@ export const GET_CANDIDATE_CLAIMS = gql` key name order + sourceText status updatedAt } @@ -40,6 +42,7 @@ export const GET_CANDIDATE_AND_CLAIMS = gql` key name order + sourceText status updatedAt } diff --git a/frontend/src/types/__generated__/boardQueries.generated.ts b/frontend/src/types/__generated__/boardQueries.generated.ts index bd397eaa93..cb7a327d0e 100644 --- a/frontend/src/types/__generated__/boardQueries.generated.ts +++ b/frontend/src/types/__generated__/boardQueries.generated.ts @@ -16,6 +16,15 @@ export type GetBoardCandidatesQueryVariables = Types.Exact<{ export type GetBoardCandidatesQuery = { boardOfDirectors: { __typename: 'BoardOfDirectorsNode', id: string, owaspUrl: string, year: number, candidates: Array<{ __typename: 'EntityMemberNode', id: string, memberName: string, memberEmail: string, description: string, member: { __typename: 'UserNode', id: string, login: string, name: string, avatarUrl: string, bio: string, createdAt: string, firstOwaspContributionAt: string | null, isOwaspBoardMember: boolean, isFormerOwaspStaff: boolean, isGsocMentor: boolean, linkedinPageId: string } | null }> } | null }; +export type GetCandidateProfileQueryVariables = Types.Exact<{ + login: Types.Scalars['String']['input']; + sessionLogin: Types.Scalars['String']['input']; + year: Types.Scalars['Int']['input']; +}>; + + +export type GetCandidateProfileQuery = { boardCandidateProfile: { __typename: 'BoardCandidateProfileNode', id: string, rawMarkdown: string, candidate: { __typename: 'EntityMemberNode', memberName: string } } | null, boardCandidateClaims: Array<{ __typename: 'BoardCandidateClaimNode', id: string, key: string, name: string, sourceText: string, status: Types.ClaimStatusEnum }>, boardOfDirectors: { __typename: 'BoardOfDirectorsNode', id: string, candidate: { __typename: 'EntityMemberNode', id: string } | null, reviewer: { __typename: 'UserNode', id: string } | null } | null }; + export type GetMemberSnapshotQueryVariables = Types.Exact<{ userLogin: Types.Scalars['String']['input']; }>; @@ -40,6 +49,7 @@ export type GetProjectByKeyQuery = { project: { __typename: 'ProjectNode', id: s export const GetBoardCandidateDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetBoardCandidate"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"year"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"login"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"boardOfDirectors"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"year"},"value":{"kind":"Variable","name":{"kind":"Name","value":"year"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"candidate"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"login"},"value":{"kind":"Variable","name":{"kind":"Name","value":"login"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}}]} as unknown as DocumentNode; export const GetBoardCandidatesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetBoardCandidates"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"year"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"boardOfDirectors"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"year"},"value":{"kind":"Variable","name":{"kind":"Name","value":"year"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"candidates"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"memberName"}},{"kind":"Field","name":{"kind":"Name","value":"memberEmail"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"member"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"bio"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"firstOwaspContributionAt"}},{"kind":"Field","name":{"kind":"Name","value":"isOwaspBoardMember"}},{"kind":"Field","name":{"kind":"Name","value":"isFormerOwaspStaff"}},{"kind":"Field","name":{"kind":"Name","value":"isGsocMentor"}},{"kind":"Field","name":{"kind":"Name","value":"linkedinPageId"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"owaspUrl"}},{"kind":"Field","name":{"kind":"Name","value":"year"}}]}}]}}]} as unknown as DocumentNode; +export const GetCandidateProfileDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetCandidateProfile"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"login"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"sessionLogin"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"year"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"boardCandidateProfile"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"login"},"value":{"kind":"Variable","name":{"kind":"Name","value":"login"}}},{"kind":"Argument","name":{"kind":"Name","value":"year"},"value":{"kind":"Variable","name":{"kind":"Name","value":"year"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"rawMarkdown"}},{"kind":"Field","name":{"kind":"Name","value":"candidate"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"memberName"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"boardCandidateClaims"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"login"},"value":{"kind":"Variable","name":{"kind":"Name","value":"login"}}},{"kind":"Argument","name":{"kind":"Name","value":"year"},"value":{"kind":"Variable","name":{"kind":"Name","value":"year"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"sourceText"}},{"kind":"Field","name":{"kind":"Name","value":"status"}}]}},{"kind":"Field","name":{"kind":"Name","value":"boardOfDirectors"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"year"},"value":{"kind":"Variable","name":{"kind":"Name","value":"year"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"candidate"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"login"},"value":{"kind":"Variable","name":{"kind":"Name","value":"login"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"reviewer"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"login"},"value":{"kind":"Variable","name":{"kind":"Name","value":"sessionLogin"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}}]} as unknown as DocumentNode; export const GetMemberSnapshotDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetMemberSnapshot"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"userLogin"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"memberSnapshot"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"userLogin"},"value":{"kind":"Variable","name":{"kind":"Name","value":"userLogin"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"channelCommunications"}},{"kind":"Field","name":{"kind":"Name","value":"chapterContributions"}},{"kind":"Field","name":{"kind":"Name","value":"commitsCount"}},{"kind":"Field","name":{"kind":"Name","value":"communicationHeatmapData"}},{"kind":"Field","name":{"kind":"Name","value":"contributionHeatmapData"}},{"kind":"Field","name":{"kind":"Name","value":"endAt"}},{"kind":"Field","name":{"kind":"Name","value":"githubUser"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"login"}}]}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"issuesCount"}},{"kind":"Field","name":{"kind":"Name","value":"messagesCount"}},{"kind":"Field","name":{"kind":"Name","value":"projectContributions"}},{"kind":"Field","name":{"kind":"Name","value":"pullRequestsCount"}},{"kind":"Field","name":{"kind":"Name","value":"repositoryContributions"}},{"kind":"Field","name":{"kind":"Name","value":"startAt"}},{"kind":"Field","name":{"kind":"Name","value":"totalContributions"}}]}}]}}]} as unknown as DocumentNode; export const GetChapterByKeyDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetChapterByKey"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"key"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"chapter"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"key"},"value":{"kind":"Variable","name":{"kind":"Name","value":"key"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}}]}}]} as unknown as DocumentNode; export const GetProjectByKeyDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetProjectByKey"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"key"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"project"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"key"},"value":{"kind":"Variable","name":{"kind":"Name","value":"key"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"level"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}}]}}]} as unknown as DocumentNode; \ No newline at end of file diff --git a/frontend/src/types/__generated__/claimMutations.generated.ts b/frontend/src/types/__generated__/claimMutations.generated.ts index 0011412cca..1fbfba47e1 100644 --- a/frontend/src/types/__generated__/claimMutations.generated.ts +++ b/frontend/src/types/__generated__/claimMutations.generated.ts @@ -6,14 +6,14 @@ export type CreateBoardCandidateClaimMutationVariables = Types.Exact<{ }>; -export type CreateBoardCandidateClaimMutation = { createBoardCandidateClaim: { __typename: 'ClaimResult', ok: boolean, code: string | null, message: string | null, claim: { __typename: 'BoardCandidateClaimNode', createdAt: any, description: string, hasEvidence: boolean, id: string, key: string, name: string, order: number, status: Types.ClaimStatusEnum, updatedAt: any } | null } }; +export type CreateBoardCandidateClaimMutation = { createBoardCandidateClaim: { __typename: 'ClaimResult', ok: boolean, code: string | null, message: string | null, claim: { __typename: 'BoardCandidateClaimNode', createdAt: any, description: string, hasEvidence: boolean, id: string, key: string, name: string, order: number, sourceText: string, status: Types.ClaimStatusEnum, updatedAt: any } | null } }; export type UpdateBoardCandidateClaimMutationVariables = Types.Exact<{ input: Types.UpdateClaimInput; }>; -export type UpdateBoardCandidateClaimMutation = { updateBoardCandidateClaim: { __typename: 'ClaimResult', ok: boolean, code: string | null, message: string | null, claim: { __typename: 'BoardCandidateClaimNode', createdAt: any, description: string, hasEvidence: boolean, id: string, key: string, name: string, order: number, status: Types.ClaimStatusEnum, updatedAt: any } | null } }; +export type UpdateBoardCandidateClaimMutation = { updateBoardCandidateClaim: { __typename: 'ClaimResult', ok: boolean, code: string | null, message: string | null, claim: { __typename: 'BoardCandidateClaimNode', createdAt: any, description: string, hasEvidence: boolean, id: string, key: string, name: string, order: number, sourceText: string, status: Types.ClaimStatusEnum, updatedAt: any } | null } }; export type DiscardBoardCandidateClaimMutationVariables = Types.Exact<{ input: Types.DiscardClaimInput; @@ -44,8 +44,8 @@ export type ReorderBoardCandidateClaimsMutationVariables = Types.Exact<{ export type ReorderBoardCandidateClaimsMutation = { reorderBoardCandidateClaims: { __typename: 'ReorderClaimsResult', ok: boolean, code: string | null, message: string | null, claims: Array<{ __typename: 'BoardCandidateClaimNode', createdAt: any, description: string, hasEvidence: boolean, id: string, key: string, name: string, order: number, status: Types.ClaimStatusEnum, updatedAt: any }> | null } }; -export const CreateBoardCandidateClaimDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateBoardCandidateClaim"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CreateClaimInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createBoardCandidateClaim"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"inputData"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"ok"}},{"kind":"Field","name":{"kind":"Name","value":"code"}},{"kind":"Field","name":{"kind":"Name","value":"message"}},{"kind":"Field","name":{"kind":"Name","value":"claim"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"hasEvidence"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"order"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]}}]} as unknown as DocumentNode; -export const UpdateBoardCandidateClaimDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateBoardCandidateClaim"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateClaimInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateBoardCandidateClaim"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"inputData"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"ok"}},{"kind":"Field","name":{"kind":"Name","value":"code"}},{"kind":"Field","name":{"kind":"Name","value":"message"}},{"kind":"Field","name":{"kind":"Name","value":"claim"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"hasEvidence"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"order"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]}}]} as unknown as DocumentNode; +export const CreateBoardCandidateClaimDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateBoardCandidateClaim"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CreateClaimInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createBoardCandidateClaim"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"inputData"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"ok"}},{"kind":"Field","name":{"kind":"Name","value":"code"}},{"kind":"Field","name":{"kind":"Name","value":"message"}},{"kind":"Field","name":{"kind":"Name","value":"claim"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"hasEvidence"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"order"}},{"kind":"Field","name":{"kind":"Name","value":"sourceText"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]}}]} as unknown as DocumentNode; +export const UpdateBoardCandidateClaimDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateBoardCandidateClaim"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateClaimInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateBoardCandidateClaim"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"inputData"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"ok"}},{"kind":"Field","name":{"kind":"Name","value":"code"}},{"kind":"Field","name":{"kind":"Name","value":"message"}},{"kind":"Field","name":{"kind":"Name","value":"claim"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"hasEvidence"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"order"}},{"kind":"Field","name":{"kind":"Name","value":"sourceText"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]}}]} as unknown as DocumentNode; export const DiscardBoardCandidateClaimDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DiscardBoardCandidateClaim"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"DiscardClaimInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"discardBoardCandidateClaim"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"inputData"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"ok"}},{"kind":"Field","name":{"kind":"Name","value":"code"}},{"kind":"Field","name":{"kind":"Name","value":"message"}},{"kind":"Field","name":{"kind":"Name","value":"claim"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"hasEvidence"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"order"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]}}]} as unknown as DocumentNode; export const SubmitBoardCandidateClaimDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SubmitBoardCandidateClaim"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"SubmitClaimInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"submitBoardCandidateClaim"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"inputData"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"ok"}},{"kind":"Field","name":{"kind":"Name","value":"code"}},{"kind":"Field","name":{"kind":"Name","value":"message"}},{"kind":"Field","name":{"kind":"Name","value":"claim"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"hasEvidence"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"order"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]}}]} as unknown as DocumentNode; export const WithdrawBoardCandidateClaimDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"WithdrawBoardCandidateClaim"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"WithdrawClaimInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"withdrawBoardCandidateClaim"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"inputData"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"ok"}},{"kind":"Field","name":{"kind":"Name","value":"code"}},{"kind":"Field","name":{"kind":"Name","value":"message"}},{"kind":"Field","name":{"kind":"Name","value":"claim"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"hasEvidence"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"order"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]}}]} as unknown as DocumentNode; diff --git a/frontend/src/types/__generated__/claimQueries.generated.ts b/frontend/src/types/__generated__/claimQueries.generated.ts index 1134f1cff6..4af43ef14c 100644 --- a/frontend/src/types/__generated__/claimQueries.generated.ts +++ b/frontend/src/types/__generated__/claimQueries.generated.ts @@ -8,7 +8,7 @@ export type GetBoardCandidateClaimQueryVariables = Types.Exact<{ }>; -export type GetBoardCandidateClaimQuery = { boardCandidateClaim: { __typename: 'BoardCandidateClaimNode', id: string, createdAt: any, description: string, key: string, name: string, status: Types.ClaimStatusEnum, updatedAt: any } | null }; +export type GetBoardCandidateClaimQuery = { boardCandidateClaim: { __typename: 'BoardCandidateClaimNode', id: string, createdAt: any, description: string, key: string, name: string, sourceText: string, status: Types.ClaimStatusEnum, updatedAt: any } | null }; export type GetBoardCandidateClaimsQueryVariables = Types.Exact<{ login: Types.Scalars['String']['input']; @@ -16,7 +16,7 @@ export type GetBoardCandidateClaimsQueryVariables = Types.Exact<{ }>; -export type GetBoardCandidateClaimsQuery = { boardCandidateClaims: Array<{ __typename: 'BoardCandidateClaimNode', id: string, createdAt: any, description: string, hasEvidence: boolean, key: string, name: string, order: number, status: Types.ClaimStatusEnum, updatedAt: any }> }; +export type GetBoardCandidateClaimsQuery = { boardCandidateClaims: Array<{ __typename: 'BoardCandidateClaimNode', id: string, createdAt: any, description: string, hasEvidence: boolean, key: string, name: string, order: number, sourceText: string, status: Types.ClaimStatusEnum, updatedAt: any }> }; export type GetBoardCandidateAndClaimsQueryVariables = Types.Exact<{ login: Types.Scalars['String']['input']; @@ -24,7 +24,7 @@ export type GetBoardCandidateAndClaimsQueryVariables = Types.Exact<{ }>; -export type GetBoardCandidateAndClaimsQuery = { boardCandidateClaims: Array<{ __typename: 'BoardCandidateClaimNode', id: string, createdAt: any, description: string, hasEvidence: boolean, key: string, name: string, order: number, status: Types.ClaimStatusEnum, updatedAt: any }>, boardOfDirectors: { __typename: 'BoardOfDirectorsNode', id: string, candidate: { __typename: 'EntityMemberNode', id: string } | null } | null }; +export type GetBoardCandidateAndClaimsQuery = { boardCandidateClaims: Array<{ __typename: 'BoardCandidateClaimNode', id: string, createdAt: any, description: string, hasEvidence: boolean, key: string, name: string, order: number, sourceText: string, status: Types.ClaimStatusEnum, updatedAt: any }>, boardOfDirectors: { __typename: 'BoardOfDirectorsNode', id: string, candidate: { __typename: 'EntityMemberNode', id: string } | null } | null }; export type GetClaimAndEvidencesQueryVariables = Types.Exact<{ login: Types.Scalars['String']['input']; @@ -37,7 +37,7 @@ export type GetClaimAndEvidencesQueryVariables = Types.Exact<{ export type GetClaimAndEvidencesQuery = { boardCandidateClaim: { __typename: 'BoardCandidateClaimNode', id: string, createdAt: any, description: string, key: string, name: string, status: Types.ClaimStatusEnum, updatedAt: any, reviews: Array<{ __typename: 'BoardCandidateClaimReviewNode', id: string, createdAt: any, notes: string, status: Types.ReviewStatusEnum, reviewer: { __typename: 'UserNode', login: string } | null }> } | null, boardCandidateClaimEvidences: Array<{ __typename: 'BoardCandidateClaimEvidenceNode', id: string, createdAt: any, description: string, hasFile: boolean, key: string, name: string, sourceUrl: string, updatedAt: any }>, boardOfDirectors: { __typename: 'BoardOfDirectorsNode', id: string, reviewer: { __typename: 'UserNode', id: string } | null } | null }; -export const GetBoardCandidateClaimDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetBoardCandidateClaim"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"login"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"key"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"year"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"boardCandidateClaim"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"login"},"value":{"kind":"Variable","name":{"kind":"Name","value":"login"}}},{"kind":"Argument","name":{"kind":"Name","value":"key"},"value":{"kind":"Variable","name":{"kind":"Name","value":"key"}}},{"kind":"Argument","name":{"kind":"Name","value":"year"},"value":{"kind":"Variable","name":{"kind":"Name","value":"year"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode; -export const GetBoardCandidateClaimsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetBoardCandidateClaims"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"login"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"year"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"boardCandidateClaims"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"login"},"value":{"kind":"Variable","name":{"kind":"Name","value":"login"}}},{"kind":"Argument","name":{"kind":"Name","value":"year"},"value":{"kind":"Variable","name":{"kind":"Name","value":"year"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"hasEvidence"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"order"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode; -export const GetBoardCandidateAndClaimsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetBoardCandidateAndClaims"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"login"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"year"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"boardCandidateClaims"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"login"},"value":{"kind":"Variable","name":{"kind":"Name","value":"login"}}},{"kind":"Argument","name":{"kind":"Name","value":"year"},"value":{"kind":"Variable","name":{"kind":"Name","value":"year"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"hasEvidence"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"order"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}},{"kind":"Field","name":{"kind":"Name","value":"boardOfDirectors"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"year"},"value":{"kind":"Variable","name":{"kind":"Name","value":"year"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"candidate"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"login"},"value":{"kind":"Variable","name":{"kind":"Name","value":"login"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}}]} as unknown as DocumentNode; +export const GetBoardCandidateClaimDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetBoardCandidateClaim"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"login"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"key"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"year"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"boardCandidateClaim"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"login"},"value":{"kind":"Variable","name":{"kind":"Name","value":"login"}}},{"kind":"Argument","name":{"kind":"Name","value":"key"},"value":{"kind":"Variable","name":{"kind":"Name","value":"key"}}},{"kind":"Argument","name":{"kind":"Name","value":"year"},"value":{"kind":"Variable","name":{"kind":"Name","value":"year"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"sourceText"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode; +export const GetBoardCandidateClaimsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetBoardCandidateClaims"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"login"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"year"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"boardCandidateClaims"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"login"},"value":{"kind":"Variable","name":{"kind":"Name","value":"login"}}},{"kind":"Argument","name":{"kind":"Name","value":"year"},"value":{"kind":"Variable","name":{"kind":"Name","value":"year"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"hasEvidence"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"order"}},{"kind":"Field","name":{"kind":"Name","value":"sourceText"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode; +export const GetBoardCandidateAndClaimsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetBoardCandidateAndClaims"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"login"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"year"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"boardCandidateClaims"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"login"},"value":{"kind":"Variable","name":{"kind":"Name","value":"login"}}},{"kind":"Argument","name":{"kind":"Name","value":"year"},"value":{"kind":"Variable","name":{"kind":"Name","value":"year"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"hasEvidence"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"order"}},{"kind":"Field","name":{"kind":"Name","value":"sourceText"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}},{"kind":"Field","name":{"kind":"Name","value":"boardOfDirectors"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"year"},"value":{"kind":"Variable","name":{"kind":"Name","value":"year"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"candidate"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"login"},"value":{"kind":"Variable","name":{"kind":"Name","value":"login"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}}]} as unknown as DocumentNode; export const GetClaimAndEvidencesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetClaimAndEvidences"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"login"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"key"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"sessionLogin"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"year"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"boardCandidateClaim"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"login"},"value":{"kind":"Variable","name":{"kind":"Name","value":"login"}}},{"kind":"Argument","name":{"kind":"Name","value":"key"},"value":{"kind":"Variable","name":{"kind":"Name","value":"key"}}},{"kind":"Argument","name":{"kind":"Name","value":"year"},"value":{"kind":"Variable","name":{"kind":"Name","value":"year"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"reviews"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"notes"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"reviewer"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"login"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"boardCandidateClaimEvidences"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"login"},"value":{"kind":"Variable","name":{"kind":"Name","value":"login"}}},{"kind":"Argument","name":{"kind":"Name","value":"claimKey"},"value":{"kind":"Variable","name":{"kind":"Name","value":"key"}}},{"kind":"Argument","name":{"kind":"Name","value":"year"},"value":{"kind":"Variable","name":{"kind":"Name","value":"year"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"hasFile"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"sourceUrl"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}},{"kind":"Field","name":{"kind":"Name","value":"boardOfDirectors"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"year"},"value":{"kind":"Variable","name":{"kind":"Name","value":"year"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"reviewer"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"login"},"value":{"kind":"Variable","name":{"kind":"Name","value":"sessionLogin"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}}]} as unknown as DocumentNode; \ No newline at end of file diff --git a/frontend/src/types/__generated__/graphql.ts b/frontend/src/types/__generated__/graphql.ts index 03140bc2ac..3cc8a3db2c 100644 --- a/frontend/src/types/__generated__/graphql.ts +++ b/frontend/src/types/__generated__/graphql.ts @@ -81,6 +81,7 @@ export type BoardCandidateClaimNode = Node & { name: Scalars['String']['output']; order: Scalars['Int']['output']; reviews: Array; + sourceText: Scalars['String']['output']; status: ClaimStatusEnum; updatedAt: Scalars['DateTime']['output']; withdrawnAt?: Maybe; @@ -97,6 +98,16 @@ export type BoardCandidateClaimReviewNode = Node & { status: ReviewStatusEnum; }; +export type BoardCandidateProfileNode = Node & { + __typename?: 'BoardCandidateProfileNode'; + candidate: EntityMemberNode; + createdAt: Scalars['DateTime']['output']; + /** The Globally Unique ID of this object */ + id: Scalars['ID']['output']; + rawMarkdown: Scalars['String']['output']; + updatedAt: Scalars['DateTime']['output']; +}; + export type BoardOfDirectorsNode = Node & { __typename?: 'BoardOfDirectorsNode'; candidate?: Maybe; @@ -198,6 +209,7 @@ export type CreateApiKeyResult = { export type CreateClaimInput = { description: Scalars['String']['input']; name: Scalars['String']['input']; + sourceText?: Scalars['String']['input']; year: Scalars['Int']['input']; }; @@ -907,6 +919,7 @@ export type Query = { boardCandidateClaimEvidenceFileUrl?: Maybe; boardCandidateClaimEvidences: Array; boardCandidateClaims: Array; + boardCandidateProfile?: Maybe; boardOfDirectors?: Maybe; boardsOfDirectors: Array; chapter?: Maybe; @@ -990,6 +1003,12 @@ export type QueryBoardCandidateClaimsArgs = { }; +export type QueryBoardCandidateProfileArgs = { + login: Scalars['String']['input']; + year: Scalars['Int']['input']; +}; + + export type QueryBoardOfDirectorsArgs = { year: Scalars['Int']['input']; }; @@ -1372,6 +1391,7 @@ export type UpdateClaimInput = { description?: InputMaybe; key: Scalars['String']['input']; name?: InputMaybe; + sourceText?: InputMaybe; year: Scalars['Int']['input']; }; From c1cf6e8eab8e9de88aa3353af1e3ef67d018cbca Mon Sep 17 00:00:00 2001 From: Rudransh Shrivastava Date: Wed, 5 Aug 2026 22:27:17 +0530 Subject: [PATCH 03/30] integrate claim highlights and candidate page with other components show source text in create/update pages. show candidate page in breadcrumbs. Signed-off-by: Rudransh Shrivastava --- e2e/pages/BoardCandidateClaimDetails.spec.ts | 1 + e2e/pages/BoardCandidateClaims.spec.ts | 7 +- .../unit/components/AnnotatedProfile.test.tsx | 108 ++++++++++++++++++ .../forms/shared/FormTextarea.test.tsx | 12 ++ .../unit/hooks/useBreadcrumbs.test.tsx | 7 +- .../unit/pages/CreateClaimPage.test.tsx | 58 ++++++++++ .../unit/pages/EditClaimPage.test.tsx | 29 +++++ .../[login]/claims/[claimKey]/edit/page.tsx | 3 + .../candidates/[login]/claims/create/page.tsx | 20 +++- .../[year]/candidates/[login]/layout.tsx | 17 --- frontend/src/components/AnnotatedProfile.tsx | 88 +++++++++++++- frontend/src/components/ClaimForm.tsx | 28 ++++- .../components/forms/shared/FormTextarea.tsx | 5 +- 13 files changed, 353 insertions(+), 30 deletions(-) delete mode 100644 frontend/src/app/board/[year]/candidates/[login]/layout.tsx diff --git a/e2e/pages/BoardCandidateClaimDetails.spec.ts b/e2e/pages/BoardCandidateClaimDetails.spec.ts index f20f5d96e9..3095a21dbf 100644 --- a/e2e/pages/BoardCandidateClaimDetails.spec.ts +++ b/e2e/pages/BoardCandidateClaimDetails.spec.ts @@ -71,6 +71,7 @@ test.describe('Board Candidate Claim Details Page', () => { await expectBreadCrumbsToBeVisible(page, [ 'Home', '2025 Board Candidates', + 'Testuser', 'Claims', 'Leadership Experience', ]) diff --git a/e2e/pages/BoardCandidateClaims.spec.ts b/e2e/pages/BoardCandidateClaims.spec.ts index 5c93bc525b..7bb9a93aad 100644 --- a/e2e/pages/BoardCandidateClaims.spec.ts +++ b/e2e/pages/BoardCandidateClaims.spec.ts @@ -57,6 +57,11 @@ test.describe('Board Candidate Claims Page', () => { }) test('breadcrumb renders correct segments', async ({ page }) => { - await expectBreadCrumbsToBeVisible(page, ['Home', '2025 Board Candidates', 'Claims']) + await expectBreadCrumbsToBeVisible(page, [ + 'Home', + '2025 Board Candidates', + 'Testuser', + 'Claims', + ]) }) }) diff --git a/frontend/__tests__/unit/components/AnnotatedProfile.test.tsx b/frontend/__tests__/unit/components/AnnotatedProfile.test.tsx index 77faa4751d..52cbf02228 100644 --- a/frontend/__tests__/unit/components/AnnotatedProfile.test.tsx +++ b/frontend/__tests__/unit/components/AnnotatedProfile.test.tsx @@ -5,6 +5,7 @@ import AnnotatedProfile, { escapeAttr, injectHighlights, normalizeIndentedHtml, + overlapsExistingClaim, renderMarkdown, resolveMediaUrls, visibleStatuses, @@ -76,6 +77,36 @@ describe('escapeAttr', () => { }) }) +describe('overlapsExistingClaim', () => { + it('returns false when the selection equals a claimed text', () => { + expect(overlapsExistingClaim('OWASP projects', ['OWASP projects'])).toBe(true) + }) + + it('returns true when the selection contains a claimed text', () => { + expect(overlapsExistingClaim('I support OWASP projects daily', ['OWASP projects'])).toBe(true) + }) + + it('returns true when the selection is contained by a claimed text', () => { + expect(overlapsExistingClaim('OWASP projects', ['support OWASP projects daily'])).toBe(true) + }) + + it('returns false when there is no overlap', () => { + expect(overlapsExistingClaim('leadership experience', ['OWASP projects'])).toBe(false) + }) + + it('returns false for an empty selection', () => { + expect(overlapsExistingClaim(' ', ['OWASP projects'])).toBe(false) + }) + + it('ignores empty claimed texts', () => { + expect(overlapsExistingClaim('OWASP projects', ['', ' '])).toBe(false) + }) + + it('trims whitespace on both sides', () => { + expect(overlapsExistingClaim(' OWASP projects ', ['OWASP projects'])).toBe(true) + }) +}) + describe('resolveMediaUrls', () => { it('resolves a relative src against the board candidates page base', () => { const html = 'Arkadii Yakovets' @@ -306,6 +337,7 @@ describe('AnnotatedProfile component', () => { afterEach(() => { cleanup() + jest.restoreAllMocks() jest.useRealTimers() }) @@ -401,4 +433,80 @@ describe('AnnotatedProfile component', () => { expect(mockPush).toHaveBeenCalledWith('/board/2025/candidates/arkid15r/claims/claim-1') expect(container.querySelector('[data-tooltip]')).toBeNull() }) + + describe('highlight-to-claim selection popup', () => { + const mockSelection = (text: string) => { + jest.spyOn(window, 'getSelection').mockReturnValue({ + isCollapsed: false, + rangeCount: 1, + removeAllRanges: () => {}, + addRange: () => {}, + toString: () => text, + getRangeAt: () => ({ + getBoundingClientRect: () => ({ + left: 100, + right: 300, + top: 50, + bottom: 70, + width: 200, + height: 20, + x: 100, + y: 50, + toJSON: () => ({}), + }), + }), + } as unknown as Selection) + } + + const mouseUpOnProfile = (container: HTMLElement) => { + const wrapper = container.querySelector('.md-wrapper') + fireEvent.mouseUp(wrapper as Element) + } + + it('shows the Create Claim popup for the owner on a non-overlapping selection', () => { + mockSelection('leadership experience') + const { container } = renderProfile({ isCandidate: true }) + mouseUpOnProfile(container) + expect(screen.getByText('Create Claim')).toBeInTheDocument() + }) + + it('does not show the popup for a non-owner', () => { + mockSelection('leadership experience') + const { container } = renderProfile({ isCandidate: false }) + mouseUpOnProfile(container) + expect(screen.queryByText('Create Claim')).not.toBeInTheDocument() + }) + + it('does not show the popup when the selection overlaps an existing claim', () => { + mockSelection('OWASP projects are great') + const { container } = renderProfile({ + claims: [claim()], + isCandidate: true, + rawMarkdown: 'OWASP projects are great.', + }) + mouseUpOnProfile(container) + expect(screen.queryByText('Create Claim')).not.toBeInTheDocument() + }) + + it('does not show the popup for an empty selection', () => { + mockSelection(' ') + const { container } = renderProfile({ isCandidate: true }) + mouseUpOnProfile(container) + expect(screen.queryByText('Create Claim')).not.toBeInTheDocument() + }) + + it('navigates to the create claim page with the encoded source text', () => { + mockSelection('leadership experience') + const { container } = renderProfile({ + isCandidate: true, + login: 'arkid15r', + year: '2025', + }) + mouseUpOnProfile(container) + fireEvent.click(screen.getByText('Create Claim')) + expect(mockPush).toHaveBeenCalledWith( + '/board/2025/candidates/arkid15r/claims/create?sourceText=leadership%20experience' + ) + }) + }) }) diff --git a/frontend/__tests__/unit/components/forms/shared/FormTextarea.test.tsx b/frontend/__tests__/unit/components/forms/shared/FormTextarea.test.tsx index e4257ad797..b6c93eaa79 100644 --- a/frontend/__tests__/unit/components/forms/shared/FormTextarea.test.tsx +++ b/frontend/__tests__/unit/components/forms/shared/FormTextarea.test.tsx @@ -55,4 +55,16 @@ describe('FormTextarea', () => { fireEvent.change(textarea, { target: { value: 'New Value' } }) expect(handleChange).toHaveBeenCalledTimes(1) }) + + it('is read-only when readOnly is true', () => { + render() + const textarea = screen.getByRole('textbox') + expect(textarea).toHaveAttribute('readonly') + }) + + it('is editable by default', () => { + render() + const textarea = screen.getByRole('textbox') + expect(textarea).not.toHaveAttribute('readonly') + }) }) diff --git a/frontend/__tests__/unit/hooks/useBreadcrumbs.test.tsx b/frontend/__tests__/unit/hooks/useBreadcrumbs.test.tsx index 68f6ee4f13..0c105f7f8f 100644 --- a/frontend/__tests__/unit/hooks/useBreadcrumbs.test.tsx +++ b/frontend/__tests__/unit/hooks/useBreadcrumbs.test.tsx @@ -141,17 +141,15 @@ describe('useBreadcrumbs', () => { unregisterLogin = registerBreadcrumb({ title: 'johndoe', path: '/board/2026/candidates/johndoe', - hidden: true, }) }) const titles = result.current.map((item) => item.title) - expect(titles).not.toContain('Johndoe') - expect(titles).not.toContain('johndoe') expect(titles).not.toContain('2026') expect(result.current).toEqual([ { title: 'Home', path: '/' }, { title: '2026 Board Candidates', path: '/board/2026/candidates' }, + { title: 'johndoe', path: '/board/2026/candidates/johndoe' }, { title: 'Claims', path: '/board/2026/candidates/johndoe/claims' }, ]) @@ -187,7 +185,6 @@ describe('useBreadcrumbs', () => { unregisterLogin = registerBreadcrumb({ title: 'johndoe', path: '/board/2026/candidates/johndoe', - hidden: true, }) unregisterEvidences = registerBreadcrumb({ title: 'Evidences', @@ -199,10 +196,10 @@ describe('useBreadcrumbs', () => { const titles = result.current.map((item) => item.title) expect(titles).not.toContain('Evidences') expect(titles).not.toContain('2026') - expect(titles).not.toContain('johndoe') expect(result.current).toEqual([ { title: 'Home', path: '/' }, { title: '2026 Board Candidates', path: '/board/2026/candidates' }, + { title: 'johndoe', path: '/board/2026/candidates/johndoe' }, { title: 'Claims', path: '/board/2026/candidates/johndoe/claims' }, { title: 'Leadership', path: '/board/2026/candidates/johndoe/claims/leadership' }, ]) diff --git a/frontend/__tests__/unit/pages/CreateClaimPage.test.tsx b/frontend/__tests__/unit/pages/CreateClaimPage.test.tsx index 66a584b552..37db575600 100644 --- a/frontend/__tests__/unit/pages/CreateClaimPage.test.tsx +++ b/frontend/__tests__/unit/pages/CreateClaimPage.test.tsx @@ -3,6 +3,7 @@ import { addToast } from '@heroui/toast' import { screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { useDjangoSession } from 'hooks/useDjangoSession' +import { useSearchParams } from 'next/navigation' import { render } from 'wrappers/testUtil' import CreateClaimPage from 'app/board/[year]/candidates/[login]/claims/create/page' @@ -15,6 +16,7 @@ jest.mock('@apollo/client/react', () => ({ jest.mock('next/navigation', () => ({ useParams: jest.fn(() => ({ login: 'testuser', year: '2025' })), useRouter: jest.fn(() => mockRouter), + useSearchParams: jest.fn(() => new URLSearchParams()), })) jest.mock('hooks/useDjangoSession', () => ({ @@ -29,6 +31,7 @@ const mockRouter = { push: jest.fn() } const mockUseMutation = useMutation as unknown as jest.Mock const mockUseQuery = useQuery as unknown as jest.Mock +const mockUseSearchParams = useSearchParams as jest.Mock const mockCreateFn = jest.fn() const mockUseDjangoSession = useDjangoSession as jest.Mock @@ -60,6 +63,7 @@ describe('CreateClaimPage', () => { }, }) mockUseMutation.mockReturnValue([mockCreateFn, { loading: false }]) + mockUseSearchParams.mockReturnValue(new URLSearchParams()) mockUseQuery.mockReturnValue({ data: { boardOfDirectors: { @@ -246,4 +250,58 @@ describe('CreateClaimPage', () => { expect(screen.queryByText('Name is required')).not.toBeInTheDocument() }) }) + + test('shows exact-match hint when no sourceText param is provided', async () => { + render() + + await waitFor(() => { + expect(screen.getByPlaceholderText('Enter claim name')).toBeInTheDocument() + }) + expect( + screen.getByText('*Must match your profile text exactly to be highlighted.') + ).toBeInTheDocument() + }) + + test('prefills and locks source text when sourceText param is provided', async () => { + mockUseSearchParams.mockReturnValue( + new URLSearchParams('sourceText=OWASP%20projects%20are%20great') + ) + + render() + + const sourceTextarea = await screen.findByDisplayValue('OWASP projects are great') + expect(sourceTextarea).toHaveAttribute('readonly') + expect( + screen.queryByText('Must match your profile text exactly to be highlighted.') + ).not.toBeInTheDocument() + }) + + test('submits sourceText in the create mutation', async () => { + render() + + await waitFor(() => { + expect(screen.getByPlaceholderText('Enter claim name')).toBeInTheDocument() + }) + + await userEvent.type(screen.getByPlaceholderText('Enter claim name'), 'New Claim') + await userEvent.type(screen.getByPlaceholderText('Enter claim description'), 'New description') + await userEvent.type( + screen.getByPlaceholderText(/paste the exact text/i), + 'OWASP projects are great' + ) + await userEvent.click(screen.getByRole('button', { name: /create claim/i })) + + await waitFor(() => { + expect(mockCreateFn).toHaveBeenCalled() + }) + expect(mockCreateFn).toHaveBeenCalledWith( + expect.objectContaining({ + variables: { + input: expect.objectContaining({ + sourceText: 'OWASP projects are great', + }), + }, + }) + ) + }) }) diff --git a/frontend/__tests__/unit/pages/EditClaimPage.test.tsx b/frontend/__tests__/unit/pages/EditClaimPage.test.tsx index 4f67fe2c27..ece531883a 100644 --- a/frontend/__tests__/unit/pages/EditClaimPage.test.tsx +++ b/frontend/__tests__/unit/pages/EditClaimPage.test.tsx @@ -53,6 +53,7 @@ const stableClaim = { description: 'Experience in leadership.', key: 'experience-leadership', name: 'Leadership Experience', + sourceText: 'OWASP projects', status: 'DRAFT', updatedAt: '2025-01-15T10:00:00Z', }, @@ -136,6 +137,10 @@ describe('EditClaimPage', () => { expect(screen.getByDisplayValue('Leadership Experience')).toBeInTheDocument() }) expect(screen.getByDisplayValue('Experience in leadership.')).toBeInTheDocument() + expect(screen.getByDisplayValue('OWASP projects')).toBeInTheDocument() + expect( + screen.getByText('*Must match your profile text exactly to be highlighted.') + ).toBeInTheDocument() }) test('submits form and redirects on success', async () => { @@ -159,6 +164,30 @@ describe('EditClaimPage', () => { ) }) + test('submits sourceText in the update mutation', async () => { + render() + + await waitFor(() => { + expect(screen.getByDisplayValue('OWASP projects')).toBeInTheDocument() + }) + + await userEvent.type(screen.getByPlaceholderText(/paste the exact text/i), ' and more') + await userEvent.click(screen.getByRole('button', { name: /edit claim/i })) + + await waitFor(() => { + expect(mockUpdateFn).toHaveBeenCalled() + }) + expect(mockUpdateFn).toHaveBeenCalledWith( + expect.objectContaining({ + variables: { + input: expect.objectContaining({ + sourceText: 'OWASP projects and more', + }), + }, + }) + ) + }) + test('shows error toast on mutation failure', async () => { mockUpdateFn.mockRejectedValue(new Error('Update failed')) diff --git a/frontend/src/app/board/[year]/candidates/[login]/claims/[claimKey]/edit/page.tsx b/frontend/src/app/board/[year]/candidates/[login]/claims/[claimKey]/edit/page.tsx index c95ce1207c..745015795e 100644 --- a/frontend/src/app/board/[year]/candidates/[login]/claims/[claimKey]/edit/page.tsx +++ b/frontend/src/app/board/[year]/candidates/[login]/claims/[claimKey]/edit/page.tsx @@ -30,6 +30,7 @@ const EditClaimPage = () => { const [formData, setFormData] = useState({ description: '', name: '', + sourceText: '', }) useEffect(() => { @@ -45,6 +46,7 @@ const EditClaimPage = () => { setFormData({ description: claim.description ?? '', name: claim.name ?? '', + sourceText: claim.sourceText ?? '', }) } }, [claim]) @@ -85,6 +87,7 @@ const EditClaimPage = () => { description: formData.description, key: claimKey, name: formData.name, + sourceText: formData.sourceText, year: Number.parseInt(year), } diff --git a/frontend/src/app/board/[year]/candidates/[login]/claims/create/page.tsx b/frontend/src/app/board/[year]/candidates/[login]/claims/create/page.tsx index f627c1d32b..e06896f736 100644 --- a/frontend/src/app/board/[year]/candidates/[login]/claims/create/page.tsx +++ b/frontend/src/app/board/[year]/candidates/[login]/claims/create/page.tsx @@ -2,8 +2,8 @@ import { useMutation, useQuery } from '@apollo/client/react' import { addToast } from '@heroui/toast' import { useDjangoSession } from 'hooks/useDjangoSession' -import { useParams, useRouter } from 'next/navigation' -import React, { useEffect, useState } from 'react' +import { useParams, useRouter, useSearchParams } from 'next/navigation' +import React, { Suspense, useEffect, useState } from 'react' import { ErrorDisplay, handleAppError } from 'app/global-error' import { GetBoardCandidateDocument } from 'types/__generated__/boardQueries.generated' @@ -14,16 +14,18 @@ import AccessDeniedDisplay from 'components/AccessDeniedDisplay' import ClaimForm from 'components/ClaimForm' import LoadingSpinner from 'components/LoadingSpinner' -const CreateClaimPage = () => { +const CreateClaimContent = () => { const router = useRouter() const { isSyncing, session } = useDjangoSession() const { login, year } = useParams<{ login: string; year: string }>() + const searchParams = useSearchParams() const [createClaim, { loading }] = useMutation(CreateBoardCandidateClaimDocument) const [formData, setFormData] = useState({ description: '', name: '', + sourceText: searchParams.get('sourceText') ?? '', }) const { @@ -73,6 +75,7 @@ const CreateClaimPage = () => { const input = { description: formData.description, name: formData.name, + sourceText: formData.sourceText, year: Number.parseInt(year), } @@ -123,6 +126,8 @@ const CreateClaimPage = () => { } } + const isSourceTextReadOnly = searchParams.get('sourceText') != null + return ( { onSubmit={handleSubmit} loading={loading} title="Create Claim" + isSourceTextReadOnly={isSourceTextReadOnly} /> ) } +const CreateClaimPage = () => { + return ( + }> + + + ) +} + export default CreateClaimPage diff --git a/frontend/src/app/board/[year]/candidates/[login]/layout.tsx b/frontend/src/app/board/[year]/candidates/[login]/layout.tsx deleted file mode 100644 index 982a7c640f..0000000000 --- a/frontend/src/app/board/[year]/candidates/[login]/layout.tsx +++ /dev/null @@ -1,17 +0,0 @@ -'use client' - -import { BreadcrumbProvider } from 'contexts/BreadcrumbContext' -import { useParams } from 'next/navigation' -import type { ReactNode } from 'react' - -export default function BoardCandidateLoginLayout({ children }: Readonly<{ children: ReactNode }>) { - const { year, login } = useParams<{ year: string; login: string }>() - - return ( - - {children} - - ) -} diff --git a/frontend/src/components/AnnotatedProfile.tsx b/frontend/src/components/AnnotatedProfile.tsx index 1bf65e0da6..3dff9c2aba 100644 --- a/frontend/src/components/AnnotatedProfile.tsx +++ b/frontend/src/components/AnnotatedProfile.tsx @@ -5,7 +5,7 @@ import { upperFirst, toLower } from 'lodash' import markdownit from 'markdown-it' import { useRouter } from 'next/navigation' import { useEffect, useMemo, useRef, useState } from 'react' -import { FaArrowRight } from 'react-icons/fa6' +import { FaArrowRight, FaPlus } from 'react-icons/fa6' import { ClaimStatusEnum } from 'types/__generated__/graphql' @@ -44,6 +44,16 @@ export function visibleStatuses(isCandidate: boolean, isReviewer: boolean): Clai ] } +export function overlapsExistingClaim(selectedText: string, claimedTexts: string[]): boolean { + const sel = selectedText.trim() + if (!sel) return false + return claimedTexts.some((raw) => { + const claimed = raw.trim() + if (!claimed) return false + return sel.includes(claimed) || claimed.includes(sel) + }) +} + export const STATUS_COLOR: Record = { [ClaimStatusEnum.Approved]: 'bg-green-200 text-green-950', [ClaimStatusEnum.Discarded]: 'bg-gray-200 text-gray-950', @@ -191,12 +201,24 @@ const AnnotatedProfile = ({ y: number width: number } | null>(null) + const [selection, setSelection] = useState<{ + text: string + x: number + y: number + width: number + range: Range + } | null>(null) const filteredClaims = useMemo( () => claims.filter((c) => visibleStatuses(isCandidate, isReviewer).includes(c.status)), [claims, isCandidate, isReviewer] ) + const claimedTexts = useMemo( + () => filteredClaims.map((c) => c.sourceText).filter(Boolean), + [filteredClaims] + ) + const html = useMemo( () => renderMarkdown(rawMarkdown, filteredClaims, year), [rawMarkdown, filteredClaims, year] @@ -223,6 +245,7 @@ const AnnotatedProfile = ({ return } if (hideTimerRef.current) clearTimeout(hideTimerRef.current) + setSelection(null) const rect = mark.getBoundingClientRect() setTooltip({ claimKey: mark.dataset.claimKey ?? '', @@ -237,18 +260,40 @@ const AnnotatedProfile = ({ const onScroll = () => { if (hideTimerRef.current) clearTimeout(hideTimerRef.current) setTooltip(null) + setSelection(null) + } + const onMouseUp = (e: MouseEvent) => { + if (!isCandidate) return + const target = e.target as HTMLElement + if (target.closest('[data-tooltip]')) return + const sel = window.getSelection() + if (!sel || sel.isCollapsed || !sel.rangeCount || sel.toString().trim() === '') { + setSelection(null) + return + } + const text = sel.toString().trim() + if (!text || overlapsExistingClaim(text, claimedTexts)) { + setSelection(null) + return + } + const range = sel.getRangeAt(0) + const rect = range.getBoundingClientRect() + setTooltip(null) + setSelection({ text, x: rect.left, y: rect.top, width: rect.width, range }) } el.addEventListener('mouseover', onMouseOver) el.addEventListener('mouseleave', onMouseLeave) + el.addEventListener('mouseup', onMouseUp) window.addEventListener('scroll', onScroll, true) return () => { el.removeEventListener('mouseover', onMouseOver) el.removeEventListener('mouseleave', onMouseLeave) + el.removeEventListener('mouseup', onMouseUp) window.removeEventListener('scroll', onScroll, true) if (hideTimerRef.current) clearTimeout(hideTimerRef.current) } - }, [html]) + }, [html, isCandidate, claimedTexts]) const handleTooltipEnter = () => { if (hideTimerRef.current) clearTimeout(hideTimerRef.current) @@ -260,12 +305,51 @@ const AnnotatedProfile = ({ router.push(`/board/${year}/candidates/${login}/claims/${tooltip.claimKey}`) } + const handleCreateClaimClick = () => { + if (!selection) return + const text = selection.text + setSelection(null) + router.push( + `/board/${year}/candidates/${login}/claims/create?sourceText=${encodeURIComponent(text)}` + ) + } + return (
+ {selection && isCandidate && ( +
+ +
+
+
+
+ )} {tooltip && (
> onSubmit: (e: React.FormEvent) => Promise loading: boolean title: string submitText?: string + isSourceTextReadOnly?: boolean } const ClaimForm = ({ @@ -34,6 +37,7 @@ const ClaimForm = ({ loading, title, submitText = 'Create Claim', + isSourceTextReadOnly = false, }: ClaimFormProps) => { const [touched, setTouched] = useState>({}) const [backendErrors, setBackendErrors] = useState>({}) @@ -94,7 +98,7 @@ const ClaimForm = ({ return ( -
+
+ + { + handleInputChange('sourceText', e.target.value) + setTouched((prev) => ({ ...prev, sourceText: true })) + }} + readOnly={isSourceTextReadOnly} + />
+ + {!isSourceTextReadOnly && ( +

+ *Must match your profile text exactly to be highlighted. +

+ )}
diff --git a/frontend/src/components/forms/shared/FormTextarea.tsx b/frontend/src/components/forms/shared/FormTextarea.tsx index 77ca1ed406..84b0629107 100644 --- a/frontend/src/components/forms/shared/FormTextarea.tsx +++ b/frontend/src/components/forms/shared/FormTextarea.tsx @@ -12,6 +12,7 @@ interface FormTextareaProps { touched?: boolean rows?: number required?: boolean + readOnly?: boolean } export const FormTextarea = ({ @@ -24,6 +25,7 @@ export const FormTextarea = ({ touched, rows = 4, required = false, + readOnly = false, }: FormTextareaProps) => { const hasError = touched && !!error @@ -40,9 +42,10 @@ export const FormTextarea = ({ onChange={onChange} rows={rows} required={required} + readOnly={readOnly} className={`w-full min-w-0 rounded-lg border px-3 py-2 text-gray-800 placeholder:text-gray-400 focus:border-[#1D7BD7] focus:ring-1 focus:ring-[#1D7BD7] focus:outline-none dark:bg-gray-800 dark:text-gray-200 dark:focus:ring-[#1D7BD7] ${ hasError ? 'border-red-500 dark:border-red-500' : 'border-gray-300 dark:border-gray-600' - }`} + } ${readOnly ? 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400' : ''}`} /> {hasError &&

{error}

}
From 4d64d9ffe39231e14246f009b6f6f9a9b580cc63 Mon Sep 17 00:00:00 2001 From: Rudransh Shrivastava Date: Fri, 7 Aug 2026 20:56:05 +0530 Subject: [PATCH 04/30] backend: apply bot comments Signed-off-by: Rudransh Shrivastava --- backend/make/apps/owasp.mk | 8 +-- .../owasp/admin/board_candidate_profile.py | 1 + .../queries/board_candidate_profile.py | 4 ++ .../commands/owasp_sync_board_candidates.py | 6 +- ...oardcandidateclaim_source_text_and_more.py | 5 +- .../owasp/models/board_candidate_profile.py | 1 + .../mutations/board_candidate_claim_test.py | 56 ++++++++++++++++++- .../board_candidate_claim_evidence_test.py | 3 + 8 files changed, 74 insertions(+), 10 deletions(-) diff --git a/backend/make/apps/owasp.mk b/backend/make/apps/owasp.mk index ea02f679bd..ab99691051 100644 --- a/backend/make/apps/owasp.mk +++ b/backend/make/apps/owasp.mk @@ -2,9 +2,9 @@ owasp-aggregate-member-contributions owasp-aggregate-projects owasp-create-project-metadata-file \ owasp-enrich-chapters owasp-enrich-committees owasp-enrich-events owasp-enrich-projects \ owasp-generate-community-snapshot-video owasp-process-snapshots owasp-scrape-chapters \ - owasp-scrape-committees owasp-scrape-projects owasp-sync-posts owasp-sync-board-candidates owasp-update-events \ - owasp-update-leaders owasp-update-project-health-metrics owasp-update-project-health-requirements \ - owasp-update-project-health-scores owasp-update-sponsors + owasp-scrape-committees owasp-scrape-projects owasp-sync-board-candidates owasp-sync-posts \ + owasp-update-events owasp-update-leaders owasp-update-project-health-metrics \ + owasp-update-project-health-requirements owasp-update-project-health-scores owasp-update-sponsors owasp-add-project-custom-tags: @echo "Adding project custom tags from $(FILE)" @@ -79,7 +79,7 @@ owasp-sync-posts: owasp-sync-board-candidates: @echo "Sync OWASP board candidates" - @CMD="python manage.py owasp_sync_board_candidates $(ARGS)" $(MAKE) exec-backend-command + @CMD="python manage.py owasp_sync_board_candidates $(ARGS)" $(MAKE) backend-exec-command owasp-update-events: @echo "Getting OWASP events data" diff --git a/backend/src/apps/owasp/admin/board_candidate_profile.py b/backend/src/apps/owasp/admin/board_candidate_profile.py index 8fb8ea521c..260d477e59 100644 --- a/backend/src/apps/owasp/admin/board_candidate_profile.py +++ b/backend/src/apps/owasp/admin/board_candidate_profile.py @@ -9,6 +9,7 @@ class BoardCandidateProfileAdmin(admin.ModelAdmin): """Admin for BoardCandidateProfile model.""" + autocomplete_fields = ("candidate",) list_display = ( "__str__", "nest_created_at", diff --git a/backend/src/apps/owasp/api/internal/queries/board_candidate_profile.py b/backend/src/apps/owasp/api/internal/queries/board_candidate_profile.py index 4ba0f1b674..52ed2321bc 100644 --- a/backend/src/apps/owasp/api/internal/queries/board_candidate_profile.py +++ b/backend/src/apps/owasp/api/internal/queries/board_candidate_profile.py @@ -7,6 +7,7 @@ from apps.owasp.api.internal.nodes.board_candidate_profile import BoardCandidateProfileNode from apps.owasp.models.board_candidate_profile import BoardCandidateProfile from apps.owasp.models.board_of_directors import BoardOfDirectors +from apps.owasp.models.entity_member import EntityMember @strawberry.type @@ -35,6 +36,9 @@ def board_candidate_profile( candidate__member__login=login, candidate__entity_type=content_type, candidate__entity_id=board.id, + candidate__role=EntityMember.Role.CANDIDATE, + candidate__is_active=True, + candidate__is_reviewed=True, ) except (BoardOfDirectors.DoesNotExist, BoardCandidateProfile.DoesNotExist): return None diff --git a/backend/src/apps/owasp/management/commands/owasp_sync_board_candidates.py b/backend/src/apps/owasp/management/commands/owasp_sync_board_candidates.py index 5336ff3536..c8f782dca6 100644 --- a/backend/src/apps/owasp/management/commands/owasp_sync_board_candidates.py +++ b/backend/src/apps/owasp/management/commands/owasp_sync_board_candidates.py @@ -13,6 +13,8 @@ from apps.owasp.models.board_of_directors import BoardOfDirectors from apps.owasp.models.entity_member import EntityMember +YAML_FRONTMATTER_PATTERN = r"^---\s*\n((?:(?!^---\s*$).*\n)+)^---\s*$" + class Command(BaseCommand): help = "Sync board election candidates from www-board-candidates repository" @@ -53,7 +55,7 @@ def parse_candidate_metadata(self, content: str) -> dict: dict: Parsed metadata dictionary. """ - yaml_pattern = re.compile(r"^---\s*\n((?:(?!^---\s*$).*\n)+)^---\s*$", re.MULTILINE) + yaml_pattern = re.compile(YAML_FRONTMATTER_PATTERN, re.MULTILINE) if not content.startswith("---"): return {} @@ -77,7 +79,7 @@ def parse_candidate_profile(self, content: str) -> str: str: Parsed profile raw text. """ - yaml_pattern = re.compile(r"^---\s*\n((?:(?!^---\s*$).*\n)+)^---\s*$", re.MULTILINE) + yaml_pattern = re.compile(YAML_FRONTMATTER_PATTERN, re.MULTILINE) if not content.startswith("---"): return content.strip() diff --git a/backend/src/apps/owasp/migrations/0081_boardcandidateclaim_source_text_and_more.py b/backend/src/apps/owasp/migrations/0081_boardcandidateclaim_source_text_and_more.py index 3b34231290..e377c2a0f3 100644 --- a/backend/src/apps/owasp/migrations/0081_boardcandidateclaim_source_text_and_more.py +++ b/backend/src/apps/owasp/migrations/0081_boardcandidateclaim_source_text_and_more.py @@ -1,4 +1,4 @@ -# Generated by Django 6.0.6 on 2026-08-01 12:21 +# Generated by Django 6.0.6 on 2026-08-07 15:32 import django.core.validators import django.db.models.deletion @@ -20,7 +20,7 @@ class Migration(migrations.Migration): field=models.TextField( blank=True, default="", - help_text="The exact text excerpt from the candidate's profile this claim refers to.", + help_text="The exact text string from the candidate's profile this claim refers to.", verbose_name="Source text", ), ), @@ -63,6 +63,7 @@ class Migration(migrations.Migration): "candidate", models.OneToOneField( help_text="The candidate this profile belongs to.", + limit_choices_to={"role": "candidate"}, on_delete=django.db.models.deletion.CASCADE, related_name="board_profile", to="owasp.entitymember", diff --git a/backend/src/apps/owasp/models/board_candidate_profile.py b/backend/src/apps/owasp/models/board_candidate_profile.py index 51947f8531..fc39c59574 100644 --- a/backend/src/apps/owasp/models/board_candidate_profile.py +++ b/backend/src/apps/owasp/models/board_candidate_profile.py @@ -18,6 +18,7 @@ class Meta: candidate = models.OneToOneField( EntityMember, help_text="The candidate this profile belongs to.", + limit_choices_to={"role": EntityMember.Role.CANDIDATE}, on_delete=models.CASCADE, related_name="board_profile", ) diff --git a/backend/tests/unit/apps/owasp/api/internal/mutations/board_candidate_claim_test.py b/backend/tests/unit/apps/owasp/api/internal/mutations/board_candidate_claim_test.py index b4e94800eb..dcc5093d71 100644 --- a/backend/tests/unit/apps/owasp/api/internal/mutations/board_candidate_claim_test.py +++ b/backend/tests/unit/apps/owasp/api/internal/mutations/board_candidate_claim_test.py @@ -518,7 +518,7 @@ class TestCreateBoardCandidateClaim: def _make_input_data( self, name="Test Claim", description="Test description", year=2025, source_text="" - ): + ) -> MagicMock: data = MagicMock() data.name = name data.description = description @@ -669,7 +669,7 @@ class TestUpdateBoardCandidateClaim: def _make_input_data( self, key="test-key", name="Updated Claim", description="Updated description", year=2025 - ): + ) -> MagicMock: data = MagicMock() data.key = key data.name = name @@ -727,6 +727,58 @@ def test_update_claim_partial(self, mock_claim_model): assert claim.name == "Updated Name" claim.save.assert_called_once_with(update_fields=["name", "key"]) + @patch("apps.owasp.api.internal.mutations.board_candidate_claim.BoardCandidateClaim") + def test_update_claim_source_text_non_empty(self, mock_claim_model): + mock_claim_model.Status = BoardCandidateClaim.Status + user = MagicMock() + user.is_authenticated = True + mock_github_user = MagicMock() + user.github_user = mock_github_user + info = _make_info(user) + input_data = MagicMock(key="test-key", description=None, year=2024) + input_data.name = "Updated Name" + input_data.source_text = "Exact text from profile" + + claim = MagicMock() + claim.candidate.member = mock_github_user + claim.is_locked = False + mock_claim_model.objects.select_for_update.return_value.get.return_value = claim + + mutation = BoardCandidateClaimMutations() + result = mutation.update_board_candidate_claim(info, input_data) + + assert result.ok + assert result.code == "SUCCESS" + assert result.claim is claim + assert claim.source_text == "Exact text from profile" + claim.save.assert_called_once_with(update_fields=["name", "key", "source_text"]) + + @patch("apps.owasp.api.internal.mutations.board_candidate_claim.BoardCandidateClaim") + def test_update_claim_source_text_clear_empty(self, mock_claim_model): + mock_claim_model.Status = BoardCandidateClaim.Status + user = MagicMock() + user.is_authenticated = True + mock_github_user = MagicMock() + user.github_user = mock_github_user + info = _make_info(user) + input_data = MagicMock(key="test-key", description=None, year=2024) + input_data.name = None + input_data.source_text = "" + + claim = MagicMock() + claim.candidate.member = mock_github_user + claim.is_locked = False + mock_claim_model.objects.select_for_update.return_value.get.return_value = claim + + mutation = BoardCandidateClaimMutations() + result = mutation.update_board_candidate_claim(info, input_data) + + assert result.ok + assert result.code == "SUCCESS" + assert result.claim is claim + assert claim.source_text == "" + claim.save.assert_called_once_with(update_fields=["source_text"]) + @patch("apps.owasp.api.internal.mutations.board_candidate_claim.BoardCandidateClaim") def test_update_claim_not_found(self, mock_claim_model): mock_claim_model.Status = BoardCandidateClaim.Status diff --git a/backend/tests/unit/apps/owasp/api/internal/queries/board_candidate_claim_evidence_test.py b/backend/tests/unit/apps/owasp/api/internal/queries/board_candidate_claim_evidence_test.py index f2339f5c49..40db7bc27c 100644 --- a/backend/tests/unit/apps/owasp/api/internal/queries/board_candidate_claim_evidence_test.py +++ b/backend/tests/unit/apps/owasp/api/internal/queries/board_candidate_claim_evidence_test.py @@ -175,6 +175,9 @@ def test_board_candidate_claim_evidences_non_self_rejected(self, mock_claim_mode info, claim_key=claim_key, login=login, year=2025 ) + mock_claim_model.objects.filter.assert_called_once_with( + candidate__member__login=login, key=claim_key, board__year=2025 + ) claim.evidences.filter.assert_called_once_with(is_removed=False) assert result == evidences_qs From 3930de5acd6db94dadcb22a3ac80d7ca72dc7241 Mon Sep 17 00:00:00 2001 From: Rudransh Shrivastava Date: Fri, 7 Aug 2026 22:21:34 +0530 Subject: [PATCH 05/30] apply frontend bot comments Signed-off-by: Rudransh Shrivastava --- .../unit/components/AnnotatedProfile.test.tsx | 154 ++++---- .../unit/pages/BoardCandidatesPage.test.tsx | 27 +- .../[login]/claims/[claimKey]/page.tsx | 18 +- .../candidates/[login]/claims/create/page.tsx | 2 +- .../board/[year]/candidates/[login]/page.tsx | 24 +- .../src/app/board/[year]/candidates/page.tsx | 16 +- frontend/src/components/AnnotatedProfile.tsx | 343 +++++++++++------- frontend/src/components/ClaimForm.tsx | 1 - .../components/forms/shared/FormTextarea.tsx | 12 +- 9 files changed, 353 insertions(+), 244 deletions(-) diff --git a/frontend/__tests__/unit/components/AnnotatedProfile.test.tsx b/frontend/__tests__/unit/components/AnnotatedProfile.test.tsx index 52cbf02228..827c9808f2 100644 --- a/frontend/__tests__/unit/components/AnnotatedProfile.test.tsx +++ b/frontend/__tests__/unit/components/AnnotatedProfile.test.tsx @@ -2,9 +2,7 @@ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { useRouter } from 'next/navigation' import { ClaimStatusEnum } from 'types/__generated__/graphql' import AnnotatedProfile, { - escapeAttr, injectHighlights, - normalizeIndentedHtml, overlapsExistingClaim, renderMarkdown, resolveMediaUrls, @@ -12,9 +10,10 @@ import AnnotatedProfile, { } from 'components/AnnotatedProfile' import type { VisibleClaim } from 'components/AnnotatedProfile' -jest.mock('dompurify', () => ({ - sanitize: (html: string) => html, -})) +jest.mock('isomorphic-dompurify', () => { + const createDOMPurify = jest.requireActual('dompurify') + return { sanitize: createDOMPurify(window).sanitize } +}) jest.mock('markdown-it', () => { const markdownIt = jest.requireActual('markdown-it') @@ -67,18 +66,8 @@ describe('visibleStatuses', () => { }) }) -describe('escapeAttr', () => { - it('escapes ampersands, quotes, and angle brackets', () => { - expect(escapeAttr('a&b"ce')).toBe('a&b"c<d>e') - }) - - it('leaves plain text unchanged', () => { - expect(escapeAttr('plain text')).toBe('plain text') - }) -}) - describe('overlapsExistingClaim', () => { - it('returns false when the selection equals a claimed text', () => { + it('returns true when the selection equals a claimed text', () => { expect(overlapsExistingClaim('OWASP projects', ['OWASP projects'])).toBe(true) }) @@ -135,122 +124,110 @@ describe('resolveMediaUrls', () => { describe('renderMarkdown', () => { it('splits paragraphs on blank lines', () => { - expect(renderMarkdown('one\n\ntwo', [], '2025')).toBe('

one

\n

two

\n') + expect(renderMarkdown('one\n\ntwo', '2025')).toBe('

one

\n

two

\n') }) it('renders headings and emphasis', () => { - expect(renderMarkdown('### Title\n\n**bold** text', [], '2025')).toBe( + expect(renderMarkdown('### Title\n\n**bold** text', '2025')).toBe( '

Title

\n

bold text

\n' ) }) it('renders markdown links', () => { - expect(renderMarkdown('[OWASP](https://owasp.org)', [], '2025')).toBe( + expect(renderMarkdown('[OWASP](https://owasp.org)', '2025')).toBe( '

OWASP

\n' ) }) it('resolves relative image paths to the board candidates site', () => { - expect(renderMarkdown('photo', [], '2025')).toBe( + expect(renderMarkdown('photo', '2025')).toBe( 'photo' ) }) it('renders indented block-level html as markup instead of a code block', () => { const markdown = ['
', ' Global Engagement', '
'].join('\n') - const result = renderMarkdown(markdown, [], '2025') + const result = renderMarkdown(markdown, '2025') expect(result).not.toContain('
')
     expect(result).toContain('
') }) - it('wraps matching source text in a mark tag', () => { - const result = renderMarkdown('I support OWASP projects and more.', [claim()], '2025') - expect(result).toContain(' { + const markdown = '' + const result = renderMarkdown(markdown, '2025') + expect(result).not.toContain('onerror') + expect(result).not.toContain('' - const result = renderMarkdown(markdown, '2025') - expect(result).not.toContain('onerror') - expect(result).not.toContain('