diff --git a/backend/make/apps/owasp.mk b/backend/make/apps/owasp.mk index e42eed9c07..5c3468f11c 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-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)" @@ -81,6 +81,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) backend-exec-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..260d477e59 --- /dev/null +++ b/backend/src/apps/owasp/admin/board_candidate_profile.py @@ -0,0 +1,41 @@ +"""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.""" + + autocomplete_fields = ("candidate",) + 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..f560a2e722 --- /dev/null +++ b/backend/src/apps/owasp/api/internal/queries/board_candidate_profile.py @@ -0,0 +1,42 @@ +"""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 +from apps.owasp.models.entity_member import EntityMember + + +@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: + return BoardCandidateProfile.objects.select_related("candidate__member").get( + candidate__member__login=login, + candidate__entity_type=ContentType.objects.get_for_model(BoardOfDirectors), + candidate__entity_id=BoardOfDirectors.objects.get(year=year).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_generate_board_candidates_claims.py b/backend/src/apps/owasp/management/commands/owasp_generate_board_candidates_claims.py index 6234ee7a3f..12eff6ecaf 100644 --- a/backend/src/apps/owasp/management/commands/owasp_generate_board_candidates_claims.py +++ b/backend/src/apps/owasp/management/commands/owasp_generate_board_candidates_claims.py @@ -14,6 +14,7 @@ from apps.common.utils import slugify from apps.github.utils import get_repository_file_content from apps.owasp.models.board_candidate_claim import BoardCandidateClaim +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 @@ -45,9 +46,10 @@ Avoid present perfect constructions such as "has done", "has been", "has contributed", etc. Return ONLY a valid JSON array of objects. -Each object must have exactly two keys: +Each object must have exactly three keys: - "name": A concise 10-20 word summary of the claim. - "description": The full contextual text of the claim. + - "source_text": A single verbatim sentence copied from the input that supports the claim. """ @@ -156,6 +158,11 @@ def generate_claims( ) return [] + try: + profile_markdown = candidate.board_profile.raw_markdown or "" + except BoardCandidateProfile.DoesNotExist: + profile_markdown = "" + claims = [] for claim_data in claims_data: if not isinstance(claim_data, dict): @@ -165,6 +172,10 @@ def generate_claims( : BoardCandidateClaim._meta.get_field("name").max_length ] description = str(claim_data.get("description") or "").strip() + source_text = str(claim_data.get("source_text") or "").strip() + + if source_text and source_text not in profile_markdown: + source_text = "" if name: claims.append( @@ -173,6 +184,7 @@ def generate_claims( description=description, candidate=candidate, name=name, + source_text=source_text, status=BoardCandidateClaim.Status.DRAFT, ) ) 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..2cc918e078 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,9 +9,12 @@ 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 +YAML_FRONTMATTER_PATTERN = r"^---\s*\n((?:(?!^---\s*$).*\n)+)^---\s*$" + class Command(BaseCommand): help = "Sync board election candidates from www-board-candidates repository" @@ -52,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 {} @@ -66,6 +69,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(YAML_FRONTMATTER_PATTERN, 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 +148,12 @@ def sync_year_candidates(self, year: int) -> int: "order": 0, } - EntityMember.update_data(data, save=True) + member = EntityMember.update_data(data, save=True) + BoardCandidateProfile.objects.update_or_create( + candidate=member, + defaults={"raw_markdown": self.parse_candidate_profile(file_content)}, + ) + 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..e377c2a0f3 --- /dev/null +++ b/backend/src/apps/owasp/migrations/0081_boardcandidateclaim_source_text_and_more.py @@ -0,0 +1,78 @@ +# Generated by Django 6.0.6 on 2026-08-07 15:32 + +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 string 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.", + limit_choices_to={"role": "candidate"}, + 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..fc39c59574 --- /dev/null +++ b/backend/src/apps/owasp/models/board_candidate_profile.py @@ -0,0 +1,33 @@ +"""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.", + limit_choices_to={"role": EntityMember.Role.CANDIDATE}, + 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..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 @@ -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="" + ) -> MagicMock: 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" @@ -665,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 @@ -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() @@ -723,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/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..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 @@ -152,6 +152,35 @@ 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 + ) + + 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 + class TestBoardCandidateClaimEvidenceSingleQuery: """Tests for board_candidate_claim_evidence single evidence query.""" @@ -282,6 +311,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 +524,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_generate_board_candidates_claims_test.py b/backend/tests/unit/apps/owasp/management/commands/owasp_generate_board_candidates_claims_test.py index 28003fcae3..957b17f1fb 100644 --- a/backend/tests/unit/apps/owasp/management/commands/owasp_generate_board_candidates_claims_test.py +++ b/backend/tests/unit/apps/owasp/management/commands/owasp_generate_board_candidates_claims_test.py @@ -8,6 +8,7 @@ from apps.common.utils import slugify from apps.owasp.management.commands.owasp_generate_board_candidates_claims import Command from apps.owasp.models.board_candidate_claim import BoardCandidateClaim +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 @@ -46,21 +47,29 @@ def test_generate_claims(self, command, mocker): ) mock_extract.return_value = json.dumps( [ - {"name": "Claim 1", "description": "Desc 1"}, - {"name": "Claim 2", "description": "Desc 2"}, + {"name": "Claim 1", "description": "Desc 1", "source_text": "founded OWASP Nest"}, + {"name": "Claim 2", "description": "Desc 2", "source_text": ""}, ] ) - candidate = EntityMember() + candidate = EntityMember(member_name="John Doe") + mocker.patch.object( + EntityMember, + "board_profile", + new_callable=mocker.PropertyMock, + return_value=Mock(raw_markdown="He founded OWASP Nest last year."), + ) board = BoardOfDirectors() claims = command.generate_claims("markdown content", candidate, board) assert len(claims) == 2 assert claims[0].name == "Claim 1" assert claims[0].description == "Desc 1" + assert claims[0].source_text == "founded OWASP Nest" assert claims[0].candidate == candidate assert claims[0].board == board assert claims[0].status == BoardCandidateClaim.Status.DRAFT + assert claims[1].source_text == "" def test_generate_claims_invalid_json(self, command, mocker): mocker.patch( @@ -103,18 +112,105 @@ def test_generate_claims_invalid_claim_data(self, command, mocker): ) mock_extract.return_value = json.dumps( [ - {"name": "Valid Claim", "description": "Valid desc"}, + { + "name": "Valid Claim", + "description": "Valid desc", + "source_text": "verbatim quote", + }, "not_a_dict", 42, ] ) - candidate = EntityMember() + candidate = EntityMember(member_name="John Doe") + mocker.patch.object( + EntityMember, + "board_profile", + new_callable=mocker.PropertyMock, + return_value=Mock(raw_markdown="This is a verbatim quote from the profile."), + ) board = BoardOfDirectors() claims = command.generate_claims("markdown content", candidate, board) assert len(claims) == 1 assert claims[0].name == "Valid Claim" + assert claims[0].source_text == "verbatim quote" + + def test_generate_claims_defaults_source_text_to_empty(self, command, mocker): + mocker.patch( + "apps.owasp.management.commands.owasp_generate_board_candidates_claims.OpenAi" + ) + mock_extract = mocker.patch( + "apps.owasp.management.commands.owasp_generate_board_candidates_claims.extract_json_from_markdown" + ) + mock_extract.return_value = json.dumps([{"name": "No Source", "description": "Some desc"}]) + + candidate = EntityMember() + board = BoardOfDirectors() + + claims = command.generate_claims("markdown content", candidate, board) + assert len(claims) == 1 + assert claims[0].source_text == "" + + def test_generate_claims_clears_source_text_missing_from_profile(self, command, mocker): + mocker.patch( + "apps.owasp.management.commands.owasp_generate_board_candidates_claims.OpenAi" + ) + mock_extract = mocker.patch( + "apps.owasp.management.commands.owasp_generate_board_candidates_claims.extract_json_from_markdown" + ) + mock_extract.return_value = json.dumps( + [ + { + "name": "In Profile", + "description": "d", + "source_text": "quote from current year", + }, + { + "name": "From Prior Year", + "description": "d", + "source_text": "quote only present in older statement", + }, + ] + ) + + candidate = EntityMember(member_name="John Doe") + mocker.patch.object( + EntityMember, + "board_profile", + new_callable=mocker.PropertyMock, + return_value=Mock(raw_markdown="Here is a quote from current year in profile."), + ) + board = BoardOfDirectors() + + claims = command.generate_claims("aggregated content", candidate, board) + assert len(claims) == 2 + assert claims[0].source_text == "quote from current year" + assert claims[1].source_text == "" + + def test_generate_claims_clears_source_text_when_profile_missing(self, command, mocker): + mocker.patch( + "apps.owasp.management.commands.owasp_generate_board_candidates_claims.OpenAi" + ) + mock_extract = mocker.patch( + "apps.owasp.management.commands.owasp_generate_board_candidates_claims.extract_json_from_markdown" + ) + mock_extract.return_value = json.dumps( + [{"name": "Claim", "description": "d", "source_text": "some quote"}] + ) + + candidate = EntityMember(member_name="John Doe") + mocker.patch.object( + EntityMember, + "board_profile", + new_callable=mocker.PropertyMock, + side_effect=BoardCandidateProfile.DoesNotExist, + ) + board = BoardOfDirectors() + + claims = command.generate_claims("markdown content", candidate, board) + assert len(claims) == 1 + assert claims[0].source_text == "" def test_generate_claims_empty_name(self, command, mocker): mocker.patch( 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 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 new file mode 100644 index 0000000000..fb369722cc --- /dev/null +++ b/frontend/__tests__/unit/components/AnnotatedProfile.test.tsx @@ -0,0 +1,290 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { useProfileSelection } from 'hooks/useProfileSelection' +import { useRouter } from 'next/navigation' +import React from 'react' + +import { ClaimStatusEnum } from 'types/__generated__/graphql' +import AnnotatedProfile from 'components/AnnotatedProfile' + +jest.mock('hooks/useProfileSelection', () => ({ + useProfileSelection: jest.fn(() => null), +})) + +type MockClaim = { key: string; name: string; status: string } + +jest.mock( + 'components/ClaimHighlight', + () => + function MockClaimHighlight({ + children, + claimsById, + 'data-id': claimId, + }: { + children?: React.ReactNode + claimsById: Map + 'data-id'?: string + }) { + const claim = claimId ? claimsById.get(claimId) : undefined + if (!claim) return <>{children} + return ( + + {children} + + ) + } +) + +const mockUseProfileSelection = useProfileSelection as jest.Mock + +const baseProps = { + claims: [], + isCandidate: false, + isReviewer: false, + login: 'alice', + rawMarkdown: 'Hello world.', + year: '2025', +} + +describe('AnnotatedProfile', () => { + beforeEach(() => { + jest.clearAllMocks() + mockUseProfileSelection.mockReturnValue(null) + }) + + it('renders plain markdown when there are no claims', () => { + render() + expect(screen.getByText('Hello')).toBeInTheDocument() + expect(document.querySelector('strong')?.textContent).toBe('Hello') + }) + + it('wraps a claim sourceText in a highlight span with data attributes', () => { + render( + + ) + const mark = screen.getByTestId('claim-highlight') + expect(mark).toHaveAttribute('data-claim-key', 'claim-key') + expect(mark).toHaveAttribute('data-claim-name', 'A Claim') + expect(mark).toHaveAttribute('data-claim-status', ClaimStatusEnum.Approved) + expect(mark.textContent).toBe('This is my claim.') + }) + + it('skips claims whose sourceText contains a blank line', () => { + render( + + ) + expect(screen.queryByTestId('claim-highlight')).not.toBeInTheDocument() + }) + + it('drops Withdrawn and Discarded claims even when sourceText matches', () => { + render( + + ) + expect(screen.queryByTestId('claim-highlight')).not.toBeInTheDocument() + }) + + it('drops claims whose sourceText is not found', () => { + render( + + ) + expect(screen.queryByTestId('claim-highlight')).not.toBeInTheDocument() + }) + + it('gives longer sourceText priority when two claims overlap', () => { + render( + + ) + const marks = screen.getAllByTestId('claim-highlight') + expect(marks).toHaveLength(1) + expect(marks[0]).toHaveTextContent('Overlap area') + expect(marks[0]).toHaveAttribute('data-claim-key', 'long') + }) + + it('highlights every occurrence of a claim sourceText', () => { + render( + + ) + const marks = screen.getAllByTestId('claim-highlight') + expect(marks).toHaveLength(2) + expect(marks[0]).toHaveAttribute('data-claim-key', 'nest') + expect(marks[1]).toHaveAttribute('data-claim-key', 'nest') + }) + + it('rewrites relative image URLs against the owasp.org base', () => { + render( + '} + /> + ) + const img = document.querySelector('img') + expect(img?.getAttribute('src')).toBe( + 'https://owasp.org/www-board-candidates/assets/images/alice/photo.jpg' + ) + }) + + it('rewrites src attributes too', () => { + render( + '} + /> + ) + const source = document.querySelector('source') + expect(source?.getAttribute('src')).toBe( + 'https://owasp.org/www-board-candidates/assets/videos/talk.mp4' + ) + }) + + it('leaves absolute image URLs unchanged', () => { + render( + '} + /> + ) + expect(document.querySelector('img')?.getAttribute('src')).toBe('https://example.com/pic.jpg') + }) + + it('does not render the Create claim button when there is no selection', () => { + render() + expect(screen.queryByRole('button', { name: /Create claim/i })).not.toBeInTheDocument() + }) + + it('does not render the Create claim button for non-candidates', () => { + mockUseProfileSelection.mockReturnValue({ + text: 'Hello world.', + rect: { top: 100, left: 50, width: 80 } as DOMRect, + }) + render() + expect(screen.queryByRole('button', { name: /Create claim/i })).not.toBeInTheDocument() + }) + + it('renders the Create claim button when a candidate has an active selection', () => { + mockUseProfileSelection.mockReturnValue({ + text: 'Hello world.', + rect: { top: 100, left: 50, width: 80 } as DOMRect, + }) + render() + expect(screen.getByRole('button', { name: /Create claim/i })).toBeInTheDocument() + }) + + it('hides the Create claim button when the selection is not an exact substring of rawMarkdown', () => { + mockUseProfileSelection.mockReturnValue({ + text: 'I lead OWASP Nest', + rect: { top: 100, left: 50, width: 80 } as DOMRect, + }) + render( + + ) + expect(screen.queryByRole('button', { name: /Create claim/i })).not.toBeInTheDocument() + }) + + it('navigates to the create-claim page with the encoded selection', () => { + const push = (useRouter() as unknown as { push: jest.Mock }).push + mockUseProfileSelection.mockReturnValue({ + text: 'Hello world.', + rect: { top: 100, left: 50, width: 80 } as DOMRect, + }) + render() + fireEvent.click(screen.getByRole('button', { name: /Create claim/i })) + expect(push).toHaveBeenCalledWith( + '/board/2025/candidates/alice/claims/create?sourceText=Hello+world.' + ) + }) +}) diff --git a/frontend/__tests__/unit/components/ClaimHighlight.test.tsx b/frontend/__tests__/unit/components/ClaimHighlight.test.tsx new file mode 100644 index 0000000000..45889a14bc --- /dev/null +++ b/frontend/__tests__/unit/components/ClaimHighlight.test.tsx @@ -0,0 +1,138 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { useRouter } from 'next/navigation' +import React from 'react' + +import { ClaimStatusEnum } from 'types/__generated__/graphql' +import { type ProfileClaim } from 'components/AnnotatedProfile' +import ClaimHighlight from 'components/ClaimHighlight' + +jest.mock('@heroui/react', () => ({ + Popover: ({ children }: { children: React.ReactNode }) =>
{children}
, + PopoverTrigger: ({ children }: { children: React.ReactNode }) => <>{children}, + PopoverContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})) + +jest.mock('@heroui/button', () => ({ + Button: ({ + children, + onPress, + className, + }: { + children: React.ReactNode + onPress?: () => void + className?: string + }) => ( + + ), +})) + +const defaultClaim: ProfileClaim = { + id: 'claim-1', + key: 'my-claim', + name: 'My Claim', + sourceText: 'claimed text', + status: ClaimStatusEnum.Approved, +} + +const renderHighlight = ( + overrides: Partial = {}, + { isCandidate = false }: { isCandidate?: boolean } = {} +) => { + const claim = { ...defaultClaim, ...overrides } + const claimsById = new Map([[claim.id, claim]]) + return render( + + claimed text + + ) +} + +describe('ClaimHighlight', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('renders children only when the claim id is not in the map', () => { + const { container } = render( + + plain text + + ) + expect(container.textContent).toBe('plain text') + expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument() + }) + + it('renders children only when no data-id is provided', () => { + const { container } = render( + + plain text + + ) + expect(container.textContent).toBe('plain text') + expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument() + }) + + it('shows only the public label to non-candidates', () => { + renderHighlight() + const popover = screen.getByTestId('popover-content') + expect(popover).toHaveTextContent('My Claim') + expect(popover).toHaveTextContent('Verified') + expect(popover).not.toHaveTextContent('Approved') + expect(screen.getByRole('button', { name: /View claim/i })).toBeInTheDocument() + }) + + it('shows both the public and candidate labels to a candidate', () => { + renderHighlight({ status: ClaimStatusEnum.Submitted }, { isCandidate: true }) + const popover = screen.getByTestId('popover-content') + expect(popover).toHaveTextContent('Under Review') + expect(popover).toHaveTextContent('Submitted') + expect( + screen.getByLabelText('Claim: My Claim, status Under Review (Submitted)') + ).toBeInTheDocument() + }) + + it('renders a gray highlight for a Submitted claim', () => { + renderHighlight({ status: ClaimStatusEnum.Submitted }) + const trigger = screen.getByLabelText(/status Under Review/) + expect(trigger.className).toMatch(/bg-gray-/) + expect(trigger.className).not.toMatch(/bg-yellow-/) + }) + + it('navigates when the View claim button is clicked', () => { + const push = (useRouter() as unknown as { push: jest.Mock }).push + renderHighlight() + fireEvent.click(screen.getByRole('button', { name: /View claim/i })) + expect(push).toHaveBeenCalledWith('/board/2025/candidates/alice/claims/my-claim') + }) + + it('falls back to Draft style when status is unknown', () => { + renderHighlight({ status: 'MYSTERY' as ClaimStatusEnum }) + expect(screen.getByTestId('popover-content')).toHaveTextContent('Draft') + }) + + it('exposes the claim in the aria-label on the trigger', () => { + renderHighlight() + expect(screen.getByLabelText('Claim: My Claim, status Verified')).toBeInTheDocument() + }) + + it('falls back to unnamed in aria-label when name is empty', () => { + renderHighlight({ name: '', status: ClaimStatusEnum.Draft }) + expect(screen.getByLabelText('Claim: unnamed, status Draft')).toBeInTheDocument() + }) +}) 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..feeeca0a8c 100644 --- a/frontend/__tests__/unit/hooks/useBreadcrumbs.test.tsx +++ b/frontend/__tests__/unit/hooks/useBreadcrumbs.test.tsx @@ -139,19 +139,18 @@ describe('useBreadcrumbs', () => { path: '/board/2026/candidates', }) unregisterLogin = registerBreadcrumb({ - title: 'johndoe', + title: 'John Doe', 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(titles).not.toContain('johndoe') expect(result.current).toEqual([ { title: 'Home', path: '/' }, { title: '2026 Board Candidates', path: '/board/2026/candidates' }, + { title: 'John Doe', path: '/board/2026/candidates/johndoe' }, { title: 'Claims', path: '/board/2026/candidates/johndoe/claims' }, ]) @@ -185,9 +184,8 @@ describe('useBreadcrumbs', () => { path: '/board/2026/candidates', }) unregisterLogin = registerBreadcrumb({ - title: 'johndoe', + title: 'John Doe', path: '/board/2026/candidates/johndoe', - hidden: true, }) unregisterEvidences = registerBreadcrumb({ title: 'Evidences', @@ -203,6 +201,7 @@ describe('useBreadcrumbs', () => { expect(result.current).toEqual([ { title: 'Home', path: '/' }, { title: '2026 Board Candidates', path: '/board/2026/candidates' }, + { title: 'John Doe', 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/hooks/useProfileSelection.test.tsx b/frontend/__tests__/unit/hooks/useProfileSelection.test.tsx new file mode 100644 index 0000000000..7fe49f01b4 --- /dev/null +++ b/frontend/__tests__/unit/hooks/useProfileSelection.test.tsx @@ -0,0 +1,163 @@ +import { act, renderHook } from '@testing-library/react' +import { useProfileSelection } from 'hooks/useProfileSelection' +import { createRef } from 'react' + +type MockRangeInit = { + text: string + collapsed?: boolean + rangeCount?: number + rect?: Partial +} + +const setupSelection = ({ + text, + collapsed = false, + rangeCount = 1, + rect = {}, + intersectsHighlight = false, +}: MockRangeInit & { intersectsHighlight?: boolean }) => { + const startContainer = document.createElement('span') + const endContainer = document.createElement('span') + const boundingRect = { top: 20, left: 10, width: 100, height: 16, ...rect } as DOMRect + const range = { + startContainer, + endContainer, + getBoundingClientRect: () => boundingRect, + intersectsNode: () => intersectsHighlight, + } + const selection = { + rangeCount, + isCollapsed: collapsed, + getRangeAt: () => range, + toString: () => text, + } + jest.spyOn(window, 'getSelection').mockReturnValue(selection as unknown as Selection) + + return { startContainer, endContainer, boundingRect } +} + +describe('useProfileSelection', () => { + afterEach(() => { + jest.restoreAllMocks() + }) + + it('returns null when disabled', () => { + const containerRef = createRef() + const { result } = renderHook(() => useProfileSelection(containerRef, false)) + expect(result.current).toBeNull() + }) + + it('captures selection when both anchors are inside the container', () => { + const container = document.createElement('div') + document.body.appendChild(container) + const { startContainer, endContainer, boundingRect } = setupSelection({ + text: ' hello world ', + }) + container.appendChild(startContainer) + container.appendChild(endContainer) + + const containerRef = { current: container } + const { result } = renderHook(() => useProfileSelection(containerRef, true)) + + act(() => { + document.dispatchEvent(new Event('selectionchange')) + }) + + expect(result.current).toEqual({ text: 'hello world', rect: boundingRect }) + }) + + it('returns null when selection is outside the container', () => { + const container = document.createElement('div') + document.body.appendChild(container) + setupSelection({ text: 'foo' }) + + const containerRef = { current: container } + const { result } = renderHook(() => useProfileSelection(containerRef, true)) + + act(() => { + document.dispatchEvent(new Event('selectionchange')) + }) + + expect(result.current).toBeNull() + }) + + it('returns null when selection has no range', () => { + setupSelection({ text: '', rangeCount: 0 }) + const containerRef = { current: document.createElement('div') } + const { result } = renderHook(() => useProfileSelection(containerRef, true)) + + act(() => { + document.dispatchEvent(new Event('selectionchange')) + }) + + expect(result.current).toBeNull() + }) + + it('returns null when selection is collapsed', () => { + const container = document.createElement('div') + const { startContainer, endContainer } = setupSelection({ + text: 'foo', + collapsed: true, + }) + container.appendChild(startContainer) + container.appendChild(endContainer) + + const containerRef = { current: container } + const { result } = renderHook(() => useProfileSelection(containerRef, true)) + + act(() => { + document.dispatchEvent(new Event('selectionchange')) + }) + + expect(result.current).toBeNull() + }) + + it('returns null when trimmed selection text is empty', () => { + const container = document.createElement('div') + const { startContainer, endContainer } = setupSelection({ + text: ' ', + }) + container.appendChild(startContainer) + container.appendChild(endContainer) + + const containerRef = { current: container } + const { result } = renderHook(() => useProfileSelection(containerRef, true)) + + act(() => { + document.dispatchEvent(new Event('selectionchange')) + }) + + expect(result.current).toBeNull() + }) + + it('returns null when the selection intersects an existing claim highlight', () => { + const container = document.createElement('div') + document.body.appendChild(container) + const highlight = document.createElement('span') + highlight.setAttribute('data-claim-highlight', 'true') + container.appendChild(highlight) + const { startContainer, endContainer } = setupSelection({ + text: 'hello', + intersectsHighlight: true, + }) + container.appendChild(startContainer) + container.appendChild(endContainer) + + const containerRef = { current: container } + const { result } = renderHook(() => useProfileSelection(containerRef, true)) + + act(() => { + document.dispatchEvent(new Event('selectionchange')) + }) + + expect(result.current).toBeNull() + }) + + it('removes listener on unmount', () => { + const removeSpy = jest.spyOn(document, 'removeEventListener') + const containerRef = createRef() + const { unmount } = renderHook(() => useProfileSelection(containerRef, true)) + unmount() + expect(removeSpy).toHaveBeenCalledWith('selectionchange', expect.any(Function)) + }) +}) diff --git a/frontend/__tests__/unit/pages/BoardCandidatesPage.test.tsx b/frontend/__tests__/unit/pages/BoardCandidatesPage.test.tsx index b72d696b55..a6921d9ba1 100644 --- a/frontend/__tests__/unit/pages/BoardCandidatesPage.test.tsx +++ b/frontend/__tests__/unit/pages/BoardCandidatesPage.test.tsx @@ -1,5 +1,5 @@ import { useQuery, useApolloClient } from '@apollo/client/react' -import { screen, waitFor } from '@testing-library/react' +import { fireEvent, screen, waitFor } from '@testing-library/react' import { render } from 'wrappers/testUtil' import BoardCandidatesPage from 'app/board/[year]/candidates/page' import { @@ -13,8 +13,11 @@ jest.mock('@apollo/client/react', () => ({ useApolloClient: jest.fn(), })) +const mockPush = jest.fn() + jest.mock('next/navigation', () => ({ useParams: jest.fn(() => ({ year: '2025' })), + useRouter: jest.fn(() => ({ push: mockPush })), })) jest.mock('app/global-error', () => ({ @@ -242,4 +245,25 @@ describe('BoardCandidatesPage', () => { expect(handleAppError).toHaveBeenCalledWith(graphQLError) }) }) + + test('navigates to the candidate profile when the card is clicked', async () => { + render() + + await waitFor(() => { + expect(screen.getByText('Alice Smith')).toBeInTheDocument() + }) + fireEvent.click(screen.getByRole('button', { name: /Alice Smith/ })) + + expect(mockPush).toHaveBeenCalledWith('/board/2025/candidates/alice') + }) + + test('does not navigate to the candidate profile when an inner link is clicked', async () => { + render() + + await waitFor(() => { + expect(screen.getByText('Alice Smith')).toBeInTheDocument() + }) + fireEvent.click(screen.getByText(/@alice/)) + expect(mockPush).not.toHaveBeenCalled() + }) }) diff --git a/frontend/__tests__/unit/pages/ClaimDetailsPage.test.tsx b/frontend/__tests__/unit/pages/ClaimDetailsPage.test.tsx index 46556d9ed9..3ecd71d2e4 100644 --- a/frontend/__tests__/unit/pages/ClaimDetailsPage.test.tsx +++ b/frontend/__tests__/unit/pages/ClaimDetailsPage.test.tsx @@ -147,4 +147,70 @@ describe('ClaimDetailsPage', () => { expect(screen.getByTestId('claim-actions')).toBeInTheDocument() }) }) + + const NON_OWNER = { user: { login: 'otheruser' } } + const ANONYMOUS = null + + const setupAccessCase = (status: string, session: { user: { login: string } } | null): void => { + mockUseDjangoSession.mockReturnValue({ + isSyncing: false, + session, + status: session ? 'authenticated' : 'unauthenticated', + }) + mockUseQuery.mockReturnValue({ + data: { + boardCandidateClaim: { ...mockSingleClaim, status }, + boardCandidateClaimEvidences: mockEvidences, + }, + loading: false, + error: null, + }) + } + + test.each([ + ['APPROVED', 'non-owner', NON_OWNER], + ['APPROVED', 'anonymous', ANONYMOUS], + ['REJECTED', 'non-owner', NON_OWNER], + ['REJECTED', 'anonymous', ANONYMOUS], + ] as const)('%s claim is visible to %s viewer', async (status, _label, session) => { + setupAccessCase(status, session) + render() + await waitFor(() => { + expect(screen.getByText(/Leadership Experience/i)).toBeInTheDocument() + }) + }) + + test.each([ + ['SUBMITTED', 'non-owner', NON_OWNER], + ['SUBMITTED', 'anonymous', ANONYMOUS], + ['DRAFT', 'non-owner', NON_OWNER], + ['DRAFT', 'anonymous', ANONYMOUS], + ] as const)('%s claim is denied for %s viewer', (status, _label, session) => { + setupAccessCase(status, session) + 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/CreateClaimPage.test.tsx b/frontend/__tests__/unit/pages/CreateClaimPage.test.tsx index 66a584b552..72496b8c87 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/__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/package.json b/frontend/package.json index 25b9865e86..19469f2692 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -35,6 +35,7 @@ "lodash": "4.18.1", "markdown-it": "14.3.0", "markdown-it-task-lists": "2.1.1", + "markdown-to-jsx": "9.8.2", "millify": "6.1.0", "next": "16.2.12", "next-auth": "4.24.15", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 3f473bf7c4..ba6b583b38 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -139,6 +139,9 @@ importers: markdown-it-task-lists: specifier: 2.1.1 version: 2.1.1 + markdown-to-jsx: + specifier: 9.8.2 + version: 9.8.2(react@19.2.7) millify: specifier: 6.1.0 version: 6.1.0 @@ -5665,6 +5668,24 @@ packages: resolution: {integrity: sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==} hasBin: true + markdown-to-jsx@9.8.2: + resolution: {integrity: sha512-rWUuxKB5NsuJmSfUOuXkQ0O5qk0J/Lr3Lk6dzxKoKQI/jeHYlsVfz3zJdMLAhI46hHoXDYERWhtBOiqtWDZ4LA==} + engines: {node: '>= 18'} + peerDependencies: + react: '>= 16.0.0' + react-native: '*' + solid-js: '>=1.0.0' + vue: '>=3.0.0' + peerDependenciesMeta: + react: + optional: true + react-native: + optional: true + solid-js: + optional: true + vue: + optional: true + marky@1.3.0: resolution: {integrity: sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==} @@ -13792,6 +13813,10 @@ snapshots: punycode.js: 2.3.1 uc.micro: 2.1.0 + markdown-to-jsx@9.8.2(react@19.2.7): + optionalDependencies: + react: 19.2.7 + marky@1.3.0: {} math-intrinsics@1.1.0: {} 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/[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,12 +70,6 @@ const ClaimDetailsPage = () => { if (isLoading || isSyncing) return - if (session?.user?.login !== login && !isReviewer) { - return ( - - ) - } - if (graphQLRequestError) { return ( { ) } + if (!canView) { + return ( + + ) + } + const claimDetails = [ { label: 'Name', value: titleCaseWord(claim.name) }, { label: 'Description', value: claim.description }, @@ -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]/claims/create/page.tsx b/frontend/src/app/board/[year]/candidates/[login]/claims/create/page.tsx index f627c1d32b..a231bf21d5 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 = Boolean(searchParams.get('sourceText')) + 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/app/board/[year]/candidates/[login]/page.tsx b/frontend/src/app/board/[year]/candidates/[login]/page.tsx new file mode 100644 index 0000000000..1715c3364e --- /dev/null +++ b/frontend/src/app/board/[year]/candidates/[login]/page.tsx @@ -0,0 +1,102 @@ +'use client' + +import { useQuery } from '@apollo/client/react' +import { registerBreadcrumb } from 'contexts/BreadcrumbContext' +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 parsedYear = Number.parseInt(year) + + const { data, error, loading } = useQuery(GetCandidateProfileDocument, { + skip: isSyncing || Number.isNaN(parsedYear), + variables: { + login, + sessionLogin: session?.user?.login ?? '', + year: parsedYear, + }, + }) + + useEffect(() => { + if (error) { + handleAppError(error) + } + }, [error]) + + const memberName = data?.boardCandidateProfile?.candidate.memberName + useEffect(() => { + if (!memberName) return + const unregister = registerBreadcrumb({ + title: memberName, + path: `/board/${year}/candidates/${login}`, + }) + return unregister + }, [memberName, login, year]) + + if (isSyncing || loading) { + return + } + + if (Number.isNaN(parsedYear)) { + return ( + + ) + } + + if (error) { + return ( + + ) + } + + const claims = data?.boardCandidateClaims ?? [] + const isCandidate = data?.boardOfDirectors?.candidate != null && session?.user?.login === login + 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..43b6091677 100644 --- a/frontend/src/app/board/[year]/candidates/page.tsx +++ b/frontend/src/app/board/[year]/candidates/page.tsx @@ -1,13 +1,12 @@ 'use client' import { useQuery, useApolloClient } from '@apollo/client/react' -import { Button } from '@heroui/button' import dayjs from 'dayjs' import relativeTime from 'dayjs/plugin/relativeTime' 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 +93,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,19 +281,30 @@ 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 const leadsFlagshipProject = ledProjects.some((project) => project.level === 'flagship') return ( - + ) } 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..035b34ab6b --- /dev/null +++ b/frontend/src/components/AnnotatedProfile.tsx @@ -0,0 +1,159 @@ +'use client' + +import { useProfileSelection } from 'hooks/useProfileSelection' +import Markdown from 'markdown-to-jsx' +import { useRouter } from 'next/navigation' +import { type ImgHTMLAttributes, type SourceHTMLAttributes, useMemo, useRef } from 'react' +import { FaPlus } from 'react-icons/fa6' + +import { ClaimStatusEnum } from 'types/__generated__/graphql' +import ClaimHighlight from 'components/ClaimHighlight' + +export type ProfileClaim = { + id: string + key: string + name: string + sourceText: string + status: ClaimStatusEnum +} + +interface AnnotatedProfileProps { + claims: ProfileClaim[] + isCandidate: boolean + login: string + rawMarkdown: string + year: string +} + +const STATUS_PRIORITY: Partial> = { + [ClaimStatusEnum.Approved]: 3, + [ClaimStatusEnum.Submitted]: 2, + [ClaimStatusEnum.Draft]: 1, + [ClaimStatusEnum.Rejected]: 0, +} + +const VISIBLE_STATUSES = new Set(Object.keys(STATUS_PRIORITY) as ClaimStatusEnum[]) + +const resolveMediaSrc = (src: T, year: string): T | string => { + if (typeof src !== 'string' || !src) return src + try { + return new URL(src, `https://owasp.org/www-board-candidates/${year}/`).href + } catch { + return src + } +} + +type WrapResult = { wrapped: string; claimsById: Map } + +const wrapClaims = (markdown: string, claims: ProfileClaim[]): WrapResult => { + const eligible = claims + .filter((c) => c.sourceText && !c.sourceText.includes('\n\n') && VISIBLE_STATUSES.has(c.status)) + .toSorted((a, b) => { + const lengthDiff = b.sourceText.length - a.sourceText.length + if (lengthDiff !== 0) return lengthDiff + return (STATUS_PRIORITY[b.status] ?? 0) - (STATUS_PRIORITY[a.status] ?? 0) + }) + + const ranges: Array<{ start: number; end: number; claim: ProfileClaim }> = [] + for (const claim of eligible) { + let searchFrom = 0 + while (searchFrom < markdown.length) { + const start = markdown.indexOf(claim.sourceText, searchFrom) + if (start < 0) break + const end = start + claim.sourceText.length + if (!ranges.some((r) => start < r.end && end > r.start)) { + ranges.push({ start, end, claim }) + } + searchFrom = end + } + } + + const claimsById = new Map() + const orderedRanges = ranges.toSorted((a, b) => b.start - a.start) + const wrapped = orderedRanges.reduce((acc, { start, end, claim }) => { + claimsById.set(claim.id, claim) + const open = `` + return `${acc.slice(0, start)}${open}${acc.slice(start, end)}${acc.slice(end)}` + }, markdown) + return { wrapped, claimsById } +} + +type MediaImgProps = ImgHTMLAttributes & { year: string } + +const MediaImg = ({ year, ...props }: MediaImgProps) => ( + // eslint-disable-next-line @next/next/no-img-element, jsx-a11y/alt-text -- candidate markdown may reference arbitrary hosts and set alt itself + +) + +type MediaSourceProps = SourceHTMLAttributes & { year: string } + +const MediaSource = ({ year, ...props }: MediaSourceProps) => ( + +) + +const AnnotatedProfile = ({ + claims, + isCandidate, + login, + rawMarkdown, + year, +}: AnnotatedProfileProps) => { + const router = useRouter() + const containerRef = useRef(null) + const selection = useProfileSelection(containerRef, isCandidate) + + const { wrapped, claimsById } = useMemo( + () => wrapClaims(rawMarkdown, claims), + [rawMarkdown, claims] + ) + const canCreateClaim = isCandidate && selection !== null && rawMarkdown.includes(selection.text) + + const markdownOptions = useMemo( + () => ({ + overrides: { + 'claim-highlight': { + component: ClaimHighlight, + props: { year, login, isCandidate, claimsById }, + }, + img: { component: MediaImg, props: { year } }, + source: { component: MediaSource, props: { year } }, + }, + }), + [year, login, isCandidate, claimsById] + ) + + const handleCreateFromSelection = () => { + if (!selection) return + const params = new URLSearchParams({ sourceText: selection.text }) + router.push(`/board/${year}/candidates/${login}/claims/create?${params}`) + } + + return ( +
+
+ {wrapped} +
+ + {canCreateClaim && selection && ( + + )} +
+ ) +} + +export default AnnotatedProfile diff --git a/frontend/src/components/ClaimForm.tsx b/frontend/src/components/ClaimForm.tsx index 6d01ee71b6..6f9beeee55 100644 --- a/frontend/src/components/ClaimForm.tsx +++ b/frontend/src/components/ClaimForm.tsx @@ -14,17 +14,20 @@ interface ClaimFormProps { formData: { description: string name: string + sourceText: string } setFormData: React.Dispatch< React.SetStateAction<{ description: string name: string + sourceText: string }> > 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) + }} + readOnly={isSourceTextReadOnly} + />
+ + {!isSourceTextReadOnly && ( +

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

+ )}
diff --git a/frontend/src/components/ClaimHighlight.tsx b/frontend/src/components/ClaimHighlight.tsx new file mode 100644 index 0000000000..3077cfc096 --- /dev/null +++ b/frontend/src/components/ClaimHighlight.tsx @@ -0,0 +1,110 @@ +import { Button } from '@heroui/button' +import { Popover, PopoverContent, PopoverTrigger } from '@heroui/react' +import { useRouter } from 'next/navigation' +import { type ReactNode } from 'react' + +import { ClaimStatusEnum } from 'types/__generated__/graphql' +import { type ProfileClaim } from 'components/AnnotatedProfile' + +type StatusStyle = { + publicLabel: string + candidateLabel: string + mark: string + badge: string +} + +const STATUS_STYLES: Record = { + [ClaimStatusEnum.Approved]: { + publicLabel: 'Verified', + candidateLabel: 'Approved', + mark: 'bg-green-200/70 text-green-900', + badge: 'bg-green-100 text-green-800 dark:bg-green-800/50 dark:text-green-100', + }, + [ClaimStatusEnum.Submitted]: { + publicLabel: 'Under Review', + candidateLabel: 'Submitted', + mark: 'bg-gray-200/70 text-gray-800', + badge: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-100', + }, + [ClaimStatusEnum.Draft]: { + publicLabel: 'Draft', + candidateLabel: 'Draft', + mark: 'bg-gray-200/70 text-gray-800', + badge: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-100', + }, + [ClaimStatusEnum.Rejected]: { + publicLabel: 'Not Verified', + candidateLabel: 'Rejected', + mark: 'bg-red-200/70 text-red-900', + badge: 'bg-red-100 text-red-800 dark:bg-red-800/50 dark:text-red-100', + }, +} + +const CANDIDATE_BADGE_CLASS = 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200' + +type ClaimHighlightProps = { + children?: ReactNode + year: string + login: string + isCandidate: boolean + claimsById: Map + 'data-id'?: string +} + +const ClaimHighlight = ({ + children, + year, + login, + isCandidate, + claimsById, + 'data-id': claimId, +}: ClaimHighlightProps) => { + const router = useRouter() + + const claim = claimId ? claimsById.get(claimId) : undefined + if (!claim) return <>{children} + + const style = STATUS_STYLES[claim.status] ?? STATUS_STYLES[ClaimStatusEnum.Draft] + const href = `/board/${year}/candidates/${login}/claims/${claim.key}` + const statusForAria = + isCandidate && style.candidateLabel !== style.publicLabel + ? `${style.publicLabel} (${style.candidateLabel})` + : style.publicLabel + const ariaLabel = `Claim: ${claim.name || 'unnamed'}, status ${statusForAria}` + const highlightClass = `cursor-pointer rounded px-0.5 transition-colors hover:brightness-95 ${style.mark}` + const badgeBaseClass = + 'inline-flex w-fit rounded-md px-1.5 py-0.5 text-[10px] font-semibold tracking-wide uppercase' + + return ( + + + + {children} + + + +
+ {claim.name} +
+ {style.publicLabel} + {isCandidate && style.candidateLabel !== style.publicLabel && ( + + {style.candidateLabel} + + )} +
+ +
+
+
+ ) +} + +export default ClaimHighlight diff --git a/frontend/src/components/forms/shared/FormTextarea.tsx b/frontend/src/components/forms/shared/FormTextarea.tsx index 77ca1ed406..00c1340ba3 100644 --- a/frontend/src/components/forms/shared/FormTextarea.tsx +++ b/frontend/src/components/forms/shared/FormTextarea.tsx @@ -2,6 +2,8 @@ import type React from 'react' +import { cn } from 'utils/utility' + interface FormTextareaProps { id: string label: string @@ -12,6 +14,7 @@ interface FormTextareaProps { touched?: boolean rows?: number required?: boolean + readOnly?: boolean } export const FormTextarea = ({ @@ -24,6 +27,7 @@ export const FormTextarea = ({ touched, rows = 4, required = false, + readOnly = false, }: FormTextareaProps) => { const hasError = touched && !!error @@ -40,9 +44,14 @@ export const FormTextarea = ({ onChange={onChange} rows={rows} required={required} - 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={readOnly} + className={cn( + '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}

} diff --git a/frontend/src/hooks/useProfileSelection.ts b/frontend/src/hooks/useProfileSelection.ts new file mode 100644 index 0000000000..3c3bca60db --- /dev/null +++ b/frontend/src/hooks/useProfileSelection.ts @@ -0,0 +1,55 @@ +import { type RefObject, useEffect, useState } from 'react' + +export type ProfileSelection = { + text: string + rect: DOMRect +} + +export const useProfileSelection = ( + containerRef: RefObject, + enabled: boolean +): ProfileSelection | null => { + const [selection, setSelection] = useState(null) + + useEffect(() => { + if (!enabled) { + setSelection(null) + return + } + + const handleSelectionChange = () => { + const container = containerRef.current + const active = window.getSelection() + + if (!container || !active || active.rangeCount === 0 || active.isCollapsed) { + setSelection(null) + return + } + + const range = active.getRangeAt(0) + if (!container.contains(range.startContainer) || !container.contains(range.endContainer)) { + setSelection(null) + return + } + + const text = active.toString().trim() + if (!text) { + setSelection(null) + return + } + + const highlights = container.querySelectorAll('[data-claim-highlight]') + if (Array.from(highlights).some((el) => range.intersectsNode(el))) { + setSelection(null) + return + } + + setSelection({ text, rect: range.getBoundingClientRect() }) + } + + document.addEventListener('selectionchange', handleSelectionChange) + return () => document.removeEventListener('selectionchange', handleSelectionChange) + }, [containerRef, enabled]) + + return selection +} diff --git a/frontend/src/server/mutations/claimMutations.ts b/frontend/src/server/mutations/claimMutations.ts index 09a915aa52..3575321947 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 } @@ -56,6 +58,7 @@ export const DISCARD_CLAIM = gql` key name order + sourceText status updatedAt } @@ -77,6 +80,7 @@ export const SUBMIT_CLAIM = gql` key name order + sourceText status updatedAt } @@ -98,6 +102,7 @@ export const WITHDRAW_CLAIM = gql` key name order + sourceText status updatedAt } @@ -119,6 +124,7 @@ export const REORDER_CLAIMS = 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 996ceb75bd..0e474228e8 100644 --- a/frontend/src/types/__generated__/boardQueries.generated.ts +++ b/frontend/src/types/__generated__/boardQueries.generated.ts @@ -5,6 +5,14 @@ export type Incremental = T | { [P in keyof T]?: P extends ' $fragmentName' | import * as Types from './graphql'; import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; +export type ClaimStatusEnum = + | 'APPROVED' + | 'DISCARDED' + | 'DRAFT' + | 'REJECTED' + | 'SUBMITTED' + | 'WITHDRAWN'; + export type GetBoardCandidateQueryVariables = Exact<{ year: number; login: string; @@ -20,6 +28,15 @@ export type GetBoardCandidatesQueryVariables = 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 = Exact<{ + login: string; + sessionLogin: string; + year: number; +}>; + + +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 = Exact<{ userLogin: string; }>; @@ -44,6 +61,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 0723a93b0a..57e7b4eed6 100644 --- a/frontend/src/types/__generated__/claimMutations.generated.ts +++ b/frontend/src/types/__generated__/claimMutations.generated.ts @@ -16,6 +16,7 @@ export type ClaimStatusEnum = export type CreateClaimInput = { description: string; name: string; + sourceText?: string; year: number; }; @@ -38,6 +39,7 @@ export type UpdateClaimInput = { description?: string | null | undefined; key: string; name?: string | null | undefined; + sourceText?: string | null | undefined; year: number; }; @@ -52,47 +54,47 @@ export type CreateBoardCandidateClaimMutationVariables = 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 = 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 = Exact<{ input: Types.DiscardClaimInput; }>; -export type DiscardBoardCandidateClaimMutation = { discardBoardCandidateClaim: { __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 DiscardBoardCandidateClaimMutation = { discardBoardCandidateClaim: { __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 SubmitBoardCandidateClaimMutationVariables = Exact<{ input: Types.SubmitClaimInput; }>; -export type SubmitBoardCandidateClaimMutation = { submitBoardCandidateClaim: { __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 SubmitBoardCandidateClaimMutation = { submitBoardCandidateClaim: { __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 WithdrawBoardCandidateClaimMutationVariables = Exact<{ input: Types.WithdrawClaimInput; }>; -export type WithdrawBoardCandidateClaimMutation = { withdrawBoardCandidateClaim: { __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 WithdrawBoardCandidateClaimMutation = { withdrawBoardCandidateClaim: { __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 ReorderBoardCandidateClaimsMutationVariables = Exact<{ input: Types.ReorderClaimsInput; }>; -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 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, sourceText: string, 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 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; -export const ReorderBoardCandidateClaimsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ReorderBoardCandidateClaims"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ReorderClaimsInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"reorderBoardCandidateClaims"},"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":"claims"},"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; \ No newline at end of file +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":"sourceText"}},{"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":"sourceText"}},{"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":"sourceText"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]}}]} as unknown as DocumentNode; +export const ReorderBoardCandidateClaimsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ReorderBoardCandidateClaims"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ReorderClaimsInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"reorderBoardCandidateClaims"},"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":"claims"},"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; \ No newline at end of file diff --git a/frontend/src/types/__generated__/claimQueries.generated.ts b/frontend/src/types/__generated__/claimQueries.generated.ts index c3d5ef43af..9ebeca4916 100644 --- a/frontend/src/types/__generated__/claimQueries.generated.ts +++ b/frontend/src/types/__generated__/claimQueries.generated.ts @@ -24,7 +24,7 @@ export type GetBoardCandidateClaimQueryVariables = 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 = Exact<{ login: string; @@ -32,7 +32,7 @@ export type GetBoardCandidateClaimsQueryVariables = 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 = Exact<{ login: string; @@ -40,7 +40,7 @@ export type GetBoardCandidateAndClaimsQueryVariables = 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 = Exact<{ login: string; @@ -53,7 +53,7 @@ export type GetClaimAndEvidencesQueryVariables = 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 2db886e451..e83fe5159b 100644 --- a/frontend/src/types/__generated__/graphql.ts +++ b/frontend/src/types/__generated__/graphql.ts @@ -76,6 +76,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; @@ -92,6 +93,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; @@ -193,6 +204,7 @@ export type CreateApiKeyResult = { export type CreateClaimInput = { description: Scalars['String']['input']; name: Scalars['String']['input']; + sourceText?: Scalars['String']['input']; year: Scalars['Int']['input']; }; @@ -902,6 +914,7 @@ export type Query = { boardCandidateClaimEvidenceFileUrl?: Maybe; boardCandidateClaimEvidences: Array; boardCandidateClaims: Array; + boardCandidateProfile?: Maybe; boardOfDirectors?: Maybe; boardsOfDirectors: Array; chapter?: Maybe; @@ -985,6 +998,12 @@ export type QueryBoardCandidateClaimsArgs = { }; +export type QueryBoardCandidateProfileArgs = { + login: Scalars['String']['input']; + year: Scalars['Int']['input']; +}; + + export type QueryBoardOfDirectorsArgs = { year: Scalars['Int']['input']; }; @@ -1367,6 +1386,7 @@ export type UpdateClaimInput = { description?: InputMaybe; key: Scalars['String']['input']; name?: InputMaybe; + sourceText?: InputMaybe; year: Scalars['Int']['input']; };