diff --git a/backend/langpro_annotator/urls.py b/backend/langpro_annotator/urls.py index f678afe..f1fd866 100644 --- a/backend/langpro_annotator/urls.py +++ b/backend/langpro_annotator/urls.py @@ -21,11 +21,14 @@ from rest_framework import routers +from problem.views.problem import ProblemView + from .index import index from .proxy_frontend import proxy_frontend from .i18n import i18n api_router = routers.DefaultRouter() # register viewsets with this router +api_router.register(r"problem", ProblemView, basename="problem") if settings.PROXY_FRONTEND: diff --git a/backend/problem/models.py b/backend/problem/models.py index b44e81f..b95916e 100644 --- a/backend/problem/models.py +++ b/backend/problem/models.py @@ -1,5 +1,4 @@ from django.db import models -from django.contrib.postgres.fields import ArrayField from django.db.models import QuerySet from problem.services import FracasData, SNLIData, SickData @@ -58,30 +57,6 @@ def get_index(self, qs: QuerySet) -> int | None: logger.exception(f"Error getting index for problem {self.pk}: {e}") return None - def serialize(self) -> dict: - """ - Serialize the Problem instance to a dictionary. - """ - - match self.dataset: - case self.Dataset.SICK: - serialized_extra_data = SickData.serialize(self.extra_data) - case self.Dataset.FRACAS: - serialized_extra_data = FracasData.serialize(self.extra_data) - case self.Dataset.SNLI: - serialized_extra_data = SNLIData.serialize(self.extra_data) - case _: - serialized_extra_data = {} - - return { - "id": self.pk, - "dataset": self.dataset, - "premises": [premise.text for premise in self.premises.all()], - "hypothesis": self.hypothesis.text, - "entailmentLabel": self.entailment_label, - "extraData": serialized_extra_data, - } - class KnowledgeBase(models.Model): class Relationship(models.TextChoices): @@ -105,3 +80,11 @@ class Relationship(models.TextChoices): on_delete=models.CASCADE, related_name="knowledge_bases", ) + + def serialize(self) -> dict: + return { + "id": self.pk, + "entity1": self.entity1, + "entity2": self.entity2, + "relationship": self.relationship, + } diff --git a/backend/problem/problem_details.py b/backend/problem/problem_details.py index 6401dd7..cd5b213 100644 --- a/backend/problem/problem_details.py +++ b/backend/problem/problem_details.py @@ -1,4 +1,3 @@ -from typing import Optional from dataclasses import dataclass from django.http import QueryDict @@ -10,16 +9,16 @@ @dataclass class RelatedProblemIds: - first: Optional[str] = None - previous: Optional[str] = None - next: Optional[str] = None - last: Optional[str] = None - total: Optional[int] = None + first: int | None = None + previous: int | None = None + next: int | None = None + last: int | None = None + total: int | None = None def get_related_problem_ids( problem_qs: QuerySet[Problem], - problem_id: Optional[int], + problem_id: int | None = None, ) -> RelatedProblemIds: """ Retrieves the IDs of surrounding problem objects @@ -44,10 +43,10 @@ def get_related_problem_ids( problem = None return RelatedProblemIds( - first=str(first_problem.pk) if first_problem else None, - previous=str(previous_problem.pk) if previous_problem else None, - next=str(next_problem.pk) if next_problem else None, - last=str(last_problem.pk) if last_problem else None, + first=first_problem.pk if first_problem else None, + previous=previous_problem.pk if previous_problem else None, + next=next_problem.pk if next_problem else None, + last=last_problem.pk if last_problem else None, total=total, ) @@ -70,8 +69,9 @@ def get_filters(query_params: QueryDict) -> Q | None: filters &= Q(dataset=dataset) if entailment_label: filters &= Q(entailment_label=entailment_label) - if gold is not None: - raise NotImplementedError() + if gold: + logger.warning(f"Filtering by gold is not implemented yet.") + pass if text: filters &= Q( Q(hypothesis__text__icontains=text) | Q(premises__text__icontains=text) diff --git a/backend/problem/serializers.py b/backend/problem/serializers.py new file mode 100644 index 0000000..10eca28 --- /dev/null +++ b/backend/problem/serializers.py @@ -0,0 +1,200 @@ +from rest_framework import serializers +from problem.services import FracasData, SNLIData, SickData +from problem.models import Problem, KnowledgeBase, Sentence + + +class KnowledgeBaseSerializer(serializers.ModelSerializer): + + class Meta: + model = KnowledgeBase + fields = ["id", "entity1", "entity2", "relationship"] + extra_kwargs = { + # Without this, the relationship field is not required during validation. + "relationship": {"required": True}, + } + + def validate_id(self, value): + """Validate that the KnowledgeBase ID exists if provided.""" + if value is not None: + if not KnowledgeBase.objects.filter(id=value).exists(): + raise serializers.ValidationError( + f"KnowledgeBase item with ID {value} does not exist." + ) + return value + + def create_for_problem( + self, validated_data: dict, problem: Problem + ) -> KnowledgeBase: + """Create a new KnowledgeBase item for a problem.""" + return KnowledgeBase.objects.create( + **validated_data, + problem=problem, + ) + + def update(self, instance: KnowledgeBase, validated_data: dict) -> KnowledgeBase: + """Update an existing KnowledgeBase item.""" + instance.entity1 = validated_data["entity1"] + instance.relationship = validated_data["relationship"] + instance.entity2 = validated_data["entity2"] + instance.save() + return instance + + +class ProblemSerializer(serializers.ModelSerializer): + """ + Serializer for Problem model output. + Handles serialization of problems with all related data including labels. + """ + + premises = serializers.SerializerMethodField() + hypothesis = serializers.SerializerMethodField() + entailmentLabel = serializers.CharField(source="entailment_label") + extraData = serializers.SerializerMethodField() + kbItems = serializers.SerializerMethodField() + + class Meta: + model = Problem + fields = [ + "id", + "dataset", + "premises", + "hypothesis", + "entailmentLabel", + "extraData", + "kbItems", + ] + + def get_premises(self, problem): + """Get list of premise texts.""" + return [premise.text for premise in problem.premises.all()] + + def get_hypothesis(self, problem): + """Get hypothesis text.""" + return problem.hypothesis.text + + def get_extraData(self, problem): + """Get dataset-specific extra data.""" + match problem.dataset: + case Problem.Dataset.SICK: + return SickData.serialize(problem.extra_data) + case Problem.Dataset.FRACAS: + return FracasData.serialize(problem.extra_data) + case Problem.Dataset.SNLI: + return SNLIData.serialize(problem.extra_data) + case _: + return {} + + def get_kbItems(self, problem): + """Get knowledge base items.""" + kb_items = problem.knowledge_bases.all() + return KnowledgeBaseSerializer(kb_items, many=True).data + + def create(self, validated_data: dict) -> Problem: + """ + Create a new Problem instance from validated input data. + Handles creation of related Sentence and KnowledgeBase objects. + """ + premise_sentences = [ + Sentence.objects.get_or_create(text=premise)[0] + for premise in validated_data["premises"] + ] + + hypothesis_sentence = Sentence.objects.get_or_create( + text=validated_data["hypothesis"] + )[0] + + problem = Problem.objects.create( + hypothesis=hypothesis_sentence, + dataset=Problem.Dataset.USER, + # TODO: Determine entailment label based on LangPro parser output. + entailment_label=Problem.EntailmentLabel.UNKNOWN, + extra_data={}, + ) + + problem.premises.set(premise_sentences) + + kb_items = validated_data.get("kbItems", []) + if kb_items: + self._update_or_create_kb_items(problem, kb_items) + + return problem + + def update(self, instance: Problem, validated_data: dict) -> Problem: + """ + Update an existing Problem instance from validated input data. + Handles updating of related Sentence and KnowledgeBase objects. + """ + if instance.dataset != Problem.Dataset.USER: + raise serializers.ValidationError( + "Cannot update a problem that is not a user-created problem." + ) + + instance.hypothesis = Sentence.objects.get_or_create( + text=validated_data["hypothesis"], + )[0] + instance.save() + + premise_sentences = [ + Sentence.objects.get_or_create(text=premise)[0] + for premise in validated_data["premises"] + ] + instance.premises.set(premise_sentences) + + self._update_or_create_kb_items(instance, validated_data.get("kbItems", [])) + + return instance + + def _update_or_create_kb_items( + self, problem: Problem, kb_items: list[dict] + ) -> None: + """Create or update KnowledgeBase items for a problem.""" + kb_ids: list[int] = [] + kb_serializer = KnowledgeBaseSerializer() + + for item in kb_items: + kb_id = item.get("id", None) + + if kb_id is None: + kb = kb_serializer.create_for_problem(item, problem=problem) # type: ignore + else: + kb_instance = KnowledgeBase.objects.get(id=kb_id, problem_id=problem.pk) + kb = kb_serializer.update(kb_instance, item) + + kb_ids.append(kb.pk) + + # Delete existing knowledge bases associated to this problem that are + # not included in the input. + KnowledgeBase.objects.filter(problem_id=problem.pk).exclude( + id__in=kb_ids + ).delete() + + +class ProblemInputSerializer(serializers.Serializer): + """ + Serializer for validating problem input data. + This is used for both creating and updating user-created problems. + """ + + id = serializers.IntegerField(required=False, allow_null=True) + premises = serializers.ListField( + child=serializers.CharField(allow_blank=False), + allow_empty=False, + help_text="List of premise sentence texts", + ) + hypothesis = serializers.CharField( + allow_blank=False, help_text="Hypothesis sentence text" + ) + kbItems = KnowledgeBaseSerializer( + many=True, allow_empty=True, help_text="List of knowledge base items" + ) + + def validate_id(self, value): + """Validate that the Problem ID, if provided, exists and belongs to a user-created problem.""" + if value is not None: + if not Problem.objects.filter( + id=value, dataset=Problem.Dataset.USER + ).exists(): + raise serializers.ValidationError( + f"Problem with ID {value} does not exist." + ) + return value diff --git a/backend/problem/serializers_test.py b/backend/problem/serializers_test.py new file mode 100644 index 0000000..ad7e489 --- /dev/null +++ b/backend/problem/serializers_test.py @@ -0,0 +1,175 @@ +import pytest +from rest_framework.exceptions import ValidationError + +from .serializers import ProblemInputSerializer +from .models import Problem, Sentence, KnowledgeBase + + +@pytest.fixture +def hypothesis_sentence(db): + return Sentence.objects.create(text="Hypothesis") + + +@pytest.fixture +def premise_sentence(db): + return Sentence.objects.create(text="Premise") + + +@pytest.fixture +def user_problem(db, hypothesis_sentence, premise_sentence): + problem = Problem.objects.create( + dataset=Problem.Dataset.USER, + hypothesis=hypothesis_sentence, + extra_data={}, + ) + problem.premises.add(premise_sentence) + return problem + + +@pytest.fixture +def non_user_problem(db, hypothesis_sentence, premise_sentence): + problem = Problem.objects.create( + dataset=Problem.Dataset.SICK, + hypothesis=hypothesis_sentence, + extra_data={}, + ) + problem.premises.add(premise_sentence) + return problem + + +@pytest.fixture +def kb_item(db, user_problem): + return KnowledgeBase.objects.create( + problem=user_problem, + entity1="e1", + entity2="e2", + relationship=KnowledgeBase.Relationship.EQUAL, + ) + + +@pytest.mark.django_db +def test_valid_create_data(): + """Test valid data for creating a problem.""" + data = { + "premises": ["A cat is running."], + "hypothesis": "A cat is moving.", + "kbItems": [], + } + serializer = ProblemInputSerializer(data=data) + assert serializer.is_valid(raise_exception=True) + + +@pytest.mark.django_db +def test_valid_update_data(user_problem, kb_item): + """Test valid data for updating a user problem.""" + data = { + "id": user_problem.pk, + "premises": ["The cat is on the mat."], + "hypothesis": "A cat is on a mat.", + "kbItems": [ + { + "id": kb_item.pk, + "entity1": "e1", + "entity2": "e2", + "relationship": "equal", + }, + {"entity1": "new_e1", "entity2": "new_e2", "relationship": "subset"}, + ], + } + serializer = ProblemInputSerializer(data=data) + assert serializer.is_valid(raise_exception=True) + + +@pytest.mark.django_db +def test_valid_create_data_no_id(): + """Test valid data for creating a problem without an ID.""" + data = { + "premises": ["A dog barks."], + "hypothesis": "A dog makes noise.", + "kbItems": [], + } + serializer = ProblemInputSerializer(data=data) + assert serializer.is_valid(raise_exception=True) + + +@pytest.mark.django_db +def test_invalid_id_non_existent(): + """Test that a non-existent problem ID is invalid.""" + data = { + "id": 9999, + "premises": ["premise"], + "hypothesis": "hypothesis", + "kbItems": [], + } + serializer = ProblemInputSerializer(data=data) + with pytest.raises(ValidationError) as exc_info: + serializer.is_valid(raise_exception=True) + assert "does not exist" in str(exc_info.value) + + +@pytest.mark.django_db +def test_invalid_id_not_user_problem(non_user_problem): + """Test that a non-user problem ID is invalid.""" + data = { + "id": non_user_problem.pk, + "premises": ["premise"], + "hypothesis": "hypothesis", + "kbItems": [], + } + serializer = ProblemInputSerializer(data=data) + with pytest.raises(ValidationError) as exc_info: + serializer.is_valid(raise_exception=True) + assert "does not exist" in str(exc_info.value) + + +@pytest.mark.django_db +def test_empty_premises_invalid(): + """Test that an empty list of premises is invalid.""" + data = {"premises": [], "hypothesis": "hypothesis", "kbItems": []} + serializer = ProblemInputSerializer(data=data) + assert not serializer.is_valid() + assert "premises" in serializer.errors + + +@pytest.mark.django_db +def test_blank_premise_invalid(): + """Test that a blank premise string is invalid.""" + data = {"premises": [""], "hypothesis": "hypothesis", "kbItems": []} + serializer = ProblemInputSerializer(data=data) + assert not serializer.is_valid() + assert "premises" in serializer.errors + + +@pytest.mark.django_db +def test_blank_hypothesis_invalid(): + """Test that a blank hypothesis is invalid.""" + data = {"premises": ["premise"], "hypothesis": "", "kbItems": []} + serializer = ProblemInputSerializer(data=data) + assert not serializer.is_valid() + assert "hypothesis" in serializer.errors + + +@pytest.mark.django_db +def test_invalid_kb_item_id(): + """Test that a non-existent kbItem ID is invalid.""" + data = { + "premises": ["premise"], + "hypothesis": "hypothesis", + "kbItems": [{"id": 9999, "relationship": "equal"}], + } + serializer = ProblemInputSerializer(data=data) + assert not serializer.is_valid() + assert "kbItems" in serializer.errors + + +@pytest.mark.django_db +def test_kb_item_missing_relationship(): + """Test that a kbItem missing a relationship is invalid.""" + data = { + "premises": ["premise"], + "hypothesis": "hypothesis", + "kbItems": [{"entity1": "e1", "entity2": "e2"}], + } + serializer = ProblemInputSerializer(data=data) + assert not serializer.is_valid() + assert "kbItems" in serializer.errors diff --git a/backend/problem/tests.py b/backend/problem/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/backend/problem/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/backend/problem/urls.py b/backend/problem/urls.py index 14a0a8e..d61e58c 100644 --- a/backend/problem/urls.py +++ b/backend/problem/urls.py @@ -1,11 +1,7 @@ from django.urls import path from problem.views.parse import ParseView -from problem.views.problem import ProblemView - urlpatterns = [ - path("", ProblemView.as_view(), name="problem_view"), path("parse", ParseView.as_view(), name="parse_view"), - path("", ProblemView.as_view(), name="first_problem_view"), ] diff --git a/backend/problem/views/problem.py b/backend/problem/views/problem.py index 360034c..f4f185a 100644 --- a/backend/problem/views/problem.py +++ b/backend/problem/views/problem.py @@ -1,56 +1,66 @@ -from dataclasses import dataclass -from typing import Optional +from rest_framework.decorators import action +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.viewsets import ModelViewSet +from rest_framework.status import HTTP_201_CREATED, HTTP_200_OK -from django.http import JsonResponse -from rest_framework.views import APIView, Request +from django.shortcuts import get_object_or_404 -from problem.problem_details import get_filters, get_related_problem_ids +from problem.problem_details import ( + get_filters, + get_related_problem_ids, +) from problem.models import Problem +from problem.serializers import ProblemInputSerializer, ProblemSerializer -@dataclass -class ProblemResponse: - id: Optional[int] = None - index: Optional[int] = None - problem: Optional[Problem] = None - error: Optional[str] = None +class ProblemView(ModelViewSet): + queryset = Problem.objects.all() + serializer_class = ProblemSerializer - first: Optional[str] = None - previous: Optional[str] = None - next: Optional[str] = None - last: Optional[str] = None - total: Optional[int] = None + def list(self, request: Request) -> Response: + """ + Lists all Problems in the database, with optional filtering. + """ + filters = get_filters(request.query_params) - def json_response(self, status=200) -> JsonResponse: - return JsonResponse( - { - "id": self.id, - "index": self.index, - "problem": self.problem.serialize() if self.problem else None, - "error": self.error, - "firstProblemId": self.first, - "previousProblemId": self.previous, - "nextProblemId": self.next, - "lastProblemId": self.last, - "totalProblems": self.total if self.total is not None else 0, - }, - status=status, - ) + qs = self.get_queryset() + if filters is not None: + qs = qs.filter(filters) -class ProblemView(APIView): - def get(self, request: Request, problem_id: int | None = None): + serializer = self.get_serializer(qs, many=True) + return Response(serializer.data, status=HTTP_200_OK) + + @action(detail=False, methods=["get"], url_path="first") + def first(self, request: Request) -> Response: + """ + Retrieves the first problem from the queryset. + """ + return self._get_problem_response(request, pk=None) + + def retrieve(self, request: Request, pk: int | None = None) -> Response: + """ + Retrieves the requested Problem by ID. + """ + return self._get_problem_response(request, pk=pk) + + def _get_problem_response(self, request: Request, pk: int | None) -> Response: + """ + Helper method to build the problem response. + If pk is provided, retrieves that problem; otherwise returns the first problem. + """ filters = get_filters(request.query_params) - qs = Problem.objects.all() + qs = self.get_queryset() if filters is not None: qs = qs.filter(filters) problem = None - if problem_id is not None: + if pk is not None: try: - problem = qs.get(id=problem_id) + problem = qs.get(id=pk) except Problem.DoesNotExist: # The selected problem may not be part of the selected filters. # In that case, we simply take the first problem from the queryset. @@ -60,15 +70,57 @@ def get(self, request: Request, problem_id: int | None = None): problem = qs.first() problem_index = problem.get_index(qs) if problem else None - related_problem_ids = get_related_problem_ids(qs, problem_id) - - return ProblemResponse( - id=problem.pk if problem else None, - index=problem_index, - problem=problem, - first=related_problem_ids.first, - previous=related_problem_ids.previous, - next=related_problem_ids.next, - last=related_problem_ids.last, - total=related_problem_ids.total, - ).json_response(status=200) + related_problem_ids = get_related_problem_ids(qs, pk) + + serializer = self.get_serializer(problem) + + return Response( + { + "problem": serializer.data, + "index": problem_index, + "first": related_problem_ids.first, + "previous": related_problem_ids.previous, + "next": related_problem_ids.next, + "last": related_problem_ids.last, + "total": related_problem_ids.total, + }, + status=HTTP_200_OK, + ) + + def create(self, request: Request) -> Response: + """ + Creates a new Problem from the provided input data. + """ + return self._handle_update_create_problem(request, problem_id=None) + + def partial_update(self, request: Request, pk: int) -> Response: + """ + Updates an existing user-created Problem with the provided input data. + """ + return self._handle_update_create_problem(request, problem_id=pk) + + def _handle_update_create_problem( + self, request: Request, problem_id: int | None + ) -> Response: + input_data = request.data + + input_serializer = ProblemInputSerializer(data=input_data) + input_serializer.is_valid(raise_exception=True) + validated_input: dict = input_serializer.validated_data # type: ignore + + problem_serializer = ProblemSerializer() + + if problem_id is None: + problem = problem_serializer.create(validated_input) + status = HTTP_201_CREATED + else: + problem_instance = get_object_or_404( + Problem, id=problem_id, dataset=Problem.Dataset.USER + ) + problem: Problem = problem_serializer.update( + problem_instance, validated_input + ) + status = HTTP_200_OK + + return Response({"id": problem.pk}, status=status) + diff --git a/frontend/src/app/annotate/annotate.component.html b/frontend/src/app/annotate/annotate.component.html index 47faefd..4441494 100644 --- a/frontend/src/app/annotate/annotate.component.html +++ b/frontend/src/app/annotate/annotate.component.html @@ -1,22 +1,91 @@ +@let firstProblemId = firstProblemId$ | async; +@let appMode = appMode$ | async; +@let browsing = appMode === 'browse'; +@let adding = appMode === 'add'; +@let editing = appMode === 'edit'; + +
+ @if (browsing) {
-
- + Add new problem + + } @else if (firstProblemId) { + + + Browse existing problems + + }
diff --git a/frontend/src/app/annotate/annotate.component.spec.ts b/frontend/src/app/annotate/annotate.component.spec.ts index 93e35c4..227ca5f 100644 --- a/frontend/src/app/annotate/annotate.component.spec.ts +++ b/frontend/src/app/annotate/annotate.component.spec.ts @@ -5,6 +5,7 @@ import { ActivatedRoute } from "@angular/router"; import { of } from "rxjs"; import { provideHttpClient } from "@angular/common/http"; import { provideHttpClientTesting } from "@angular/common/http/testing"; +import { CommonModule } from "@angular/common"; describe("AnnotateComponent", () => { let component: AnnotateComponent; @@ -12,13 +13,15 @@ describe("AnnotateComponent", () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [AnnotateComponent], + imports: [AnnotateComponent, CommonModule], providers: [ provideHttpClient(), provideHttpClientTesting(), { provide: ActivatedRoute, useValue: { + url: of([]), + paramMap: of({}), queryParamMap: of({}) } } diff --git a/frontend/src/app/annotate/annotate.component.ts b/frontend/src/app/annotate/annotate.component.ts index 225072e..05f2840 100644 --- a/frontend/src/app/annotate/annotate.component.ts +++ b/frontend/src/app/annotate/annotate.component.ts @@ -1,10 +1,16 @@ -import { Component } from "@angular/core"; +import { Component, DestroyRef, inject, OnInit } from "@angular/core"; import { AnnotationMenuComponent } from "./annotation-menu/annotation-menu.component"; import { NavigatorComponent } from "./navigator/navigator.component"; import { AnnotationInputComponent } from "./annotation-input/annotation-input.component"; import { SearchComponent } from "./search/search.component"; import { FontAwesomeModule } from "@fortawesome/angular-fontawesome"; -import { faTree } from "@fortawesome/free-solid-svg-icons"; +import { faBinoculars, faPlus } from "@fortawesome/free-solid-svg-icons"; +import { ActivatedRoute, RouterLinkWithHref } from "@angular/router"; +import { ProblemService } from "@/services/problem.service"; +import { combineLatest, distinctUntilChanged, map } from "rxjs"; +import { CommonModule } from "@angular/common"; +import { Dataset } from "@/types"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; @Component({ selector: "la-annotate", @@ -15,10 +21,43 @@ import { faTree } from "@fortawesome/free-solid-svg-icons"; AnnotationInputComponent, SearchComponent, FontAwesomeModule, + CommonModule, + RouterLinkWithHref, ], templateUrl: "./annotate.component.html", styleUrl: "./annotate.component.scss", }) -export class AnnotateComponent { - public faTree = faTree; +export class AnnotateComponent implements OnInit { + private route = inject(ActivatedRoute); + private problemService = inject(ProblemService); + private destroyRef = inject(DestroyRef); + + public faPlus = faPlus; + public faBinoculars = faBinoculars; + + public appMode$ = this.problemService.appMode$; + public firstProblemId$ = this.problemService.firstProblemId$; + + public isUserProblem$ = this.problemService.problem$.pipe( + map(problem => problem?.dataset === Dataset.USER) + ); + + ngOnInit(): void { + const editParam$ = this.route.url.pipe( + map(segments => segments.some(segment => segment.path === "edit")), + distinctUntilChanged(), + ); + + combineLatest([ + this.route.paramMap, + this.route.queryParamMap, + editParam$ + ]) + .pipe( + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(([params, queryParams, edit]) => { + this.problemService.allParams$.next({ params, queryParams, edit }); + }); + } } diff --git a/frontend/src/app/annotate/annotation-input/annotation-input.component.html b/frontend/src/app/annotate/annotation-input/annotation-input.component.html index 0aafa9d..5a1789d 100644 --- a/frontend/src/app/annotate/annotation-input/annotation-input.component.html +++ b/frontend/src/app/annotate/annotation-input/annotation-input.component.html @@ -1,12 +1,57 @@ -@if (problem?.id) { +@let problem = problem$ | async; +@let appMode = appMode$ | async; +@let userProblem = isUserProblem$ | async; +@if (problem) {
@if (form) {
- +
- + +
+
+ + @if ((appMode === 'edit' || appMode === 'add') && userProblem) { + + } @else if (userProblem) { + + + Edit problem + + }
}
diff --git a/frontend/src/app/annotate/annotation-input/annotation-input.component.spec.ts b/frontend/src/app/annotate/annotation-input/annotation-input.component.spec.ts index 0e14e8d..3e5bca1 100644 --- a/frontend/src/app/annotate/annotation-input/annotation-input.component.spec.ts +++ b/frontend/src/app/annotate/annotation-input/annotation-input.component.spec.ts @@ -1,30 +1,41 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { FormArray, FormGroup } from "@angular/forms"; import { AnnotationInputComponent } from "./annotation-input.component"; import { provideHttpClientTesting } from "@angular/common/http/testing"; import { provideHttpClient } from "@angular/common/http"; -import { ActivatedRoute } from "@angular/router"; +import { ActivatedRoute, Router } from "@angular/router"; import { of } from "rxjs"; +import { Dataset, KnowledgeBaseRelationship, Problem, EntailmentLabel } from "../../types"; describe("AnnotationInputComponent", () => { let component: AnnotationInputComponent; let fixture: ComponentFixture; + let mockRouter: jasmine.SpyObj; + let mockActivatedRoute: any; beforeEach(async () => { + const routerSpy = jasmine.createSpyObj('Router', ['navigate']); + mockActivatedRoute = { + params: of({ problemId: "1" }), + snapshot: { + paramMap: { + get: jasmine.createSpy('get').and.returnValue("17") + } + } + }; + await TestBed.configureTestingModule({ imports: [AnnotationInputComponent], providers: [ provideHttpClient(), provideHttpClientTesting(), - { - provide: ActivatedRoute, - useValue: { - params: of({ problemId: "1" }), - }, - }, + { provide: ActivatedRoute, useValue: mockActivatedRoute }, + { provide: Router, useValue: routerSpy } ], }).compileComponents(); + mockRouter = TestBed.inject(Router) as jasmine.SpyObj; fixture = TestBed.createComponent(AnnotationInputComponent); component = fixture.componentInstance; fixture.detectChanges(); @@ -33,4 +44,147 @@ describe("AnnotationInputComponent", () => { it("should create", () => { expect(component).toBeTruthy(); }); + + describe('buildForm', () => { + it('should build form with correct structure and values from problem data', () => { + const mockProblem: Problem = { + id: 123, + premises: ["First premise", "Second premise"], + hypothesis: "Test hypothesis", + entailmentLabel: EntailmentLabel.ENTAILMENT, + kbItems: [ + { + id: 456, + entity1: "cat", + entity2: "animal", + relationship: KnowledgeBaseRelationship.SUBSET + }, + { + id: 789, + entity1: "dog", + entity2: "pet", + relationship: KnowledgeBaseRelationship.EQUAL + } + ], + dataset: Dataset.USER, + extraData: null + }; + + // Access private method. + const form = component['buildForm'](mockProblem); + + // Test form structure + expect(form).toBeTruthy(); + expect(form.get('id')?.value).toBe(123); + expect(form.get('hypothesis')?.value).toBe('Test hypothesis'); + + // Test premises array + const premisesArray = form.controls.premises; + expect(premisesArray.length).toBe(2); + expect(premisesArray.controls[0].value).toBe('First premise'); + expect(premisesArray.controls[1].value).toBe('Second premise'); + + // Test knowledge base items + const kbItemsArray = form.controls.kbItems; + expect(kbItemsArray.length).toBe(2); + + const firstKbForm = kbItemsArray.controls[0]; + expect(firstKbForm.value.id).toBe(456); + expect(firstKbForm.value.entity1).toBe('cat'); + expect(firstKbForm.value.entity2).toBe('animal'); + expect(firstKbForm.value.relationship).toBe(KnowledgeBaseRelationship.SUBSET); + + const secondKbForm = kbItemsArray.controls[1]; + expect(secondKbForm.value.id).toBe(789); + expect(secondKbForm.value.entity1).toBe('dog'); + expect(secondKbForm.value.entity2).toBe('pet'); + expect(secondKbForm.value.relationship).toBe(KnowledgeBaseRelationship.EQUAL); + }); + + it('should handle empty premises and kbItems arrays', () => { + const mockProblem: Problem = { + id: 123, + premises: [], + hypothesis: "Empty test hypothesis", + entailmentLabel: EntailmentLabel.NEUTRAL, + kbItems: [], + dataset: Dataset.USER, + extraData: null + }; + + const form = component['buildForm'](mockProblem); + + expect(form.get('id')?.value).toBe(123); + expect(form.get('hypothesis')?.value).toBe('Empty test hypothesis'); + + const premisesArray = form.get('premises') as FormArray; + expect(premisesArray.length).toBe(0); + + const kbItemsArray = form.get('kbItems') as FormArray; + expect(kbItemsArray.length).toBe(0); + }); + + it('should create form controls with required validators', () => { + const mockProblem: Problem = { + id: 1, + premises: ["Test premise"], + hypothesis: "Test hypothesis", + entailmentLabel: EntailmentLabel.CONTRADICTION, + kbItems: [], + dataset: Dataset.USER, + extraData: null + }; + + const form = component['buildForm'](mockProblem); + + const hypothesisControl = form.get('hypothesis'); + expect(hypothesisControl?.hasError('required')).toBeFalsy(); + hypothesisControl?.setValue(''); + expect(hypothesisControl?.hasError('required')).toBeTruthy(); + }); + }); + + describe('navigateToNewProblem', () => { + it('should navigate when problem ID is different from current route', () => { + const mockProblem: Problem = { + id: 12, + premises: [], + hypothesis: "", + entailmentLabel: EntailmentLabel.UNKNOWN, + kbItems: [], + dataset: Dataset.USER, + extraData: null + }; + + component['navigateToNewProblem'](mockProblem); + + expect(mockRouter.navigate).toHaveBeenCalledWith( + ['/annotate', 12], + { queryParamsHandling: 'preserve' } + ); + }); + + it('should not navigate when problem ID matches current route', () => { + const mockProblem: Problem = { + id: 17, + premises: [], + hypothesis: "", + entailmentLabel: EntailmentLabel.UNKNOWN, + kbItems: [], + dataset: Dataset.USER, + extraData: null + }; + + component['navigateToNewProblem'](mockProblem); + + expect(mockRouter.navigate).not.toHaveBeenCalled(); + }); + + it('should not navigate when problem is null', () => { + // Call the private method with null + component['navigateToNewProblem'](null); + + expect(mockRouter.navigate).not.toHaveBeenCalled(); + }); + }); }); diff --git a/frontend/src/app/annotate/annotation-input/annotation-input.component.ts b/frontend/src/app/annotate/annotation-input/annotation-input.component.ts index 32b6980..04dd8fa 100644 --- a/frontend/src/app/annotate/annotation-input/annotation-input.component.ts +++ b/frontend/src/app/annotate/annotation-input/annotation-input.component.ts @@ -9,27 +9,27 @@ import { Validators, } from "@angular/forms"; import { PremisesFormComponent } from "./premises-form/premises-form.component"; -import { - KnowledgeBaseFormComponent, - KnowledgeBaseRelationship, -} from "./knowledge-base-form/knowledge-base-form.component"; +import { KnowledgeBaseFormComponent } from "./knowledge-base-form/knowledge-base-form.component"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; -import { ProblemResponse } from "../../types"; -import { faExclamationCircle } from "@fortawesome/free-solid-svg-icons"; +import { Dataset, KnowledgeBaseRelationship, Problem } from "../../types"; +import { faCheck, faExclamationCircle, faFloppyDisk, faTrash, faTree, faWrench } from "@fortawesome/free-solid-svg-icons"; import { ProblemDetailsComponent } from "./problem-details/problem-details.component"; -import { combineLatest, Subject } from "rxjs"; -import { ActivatedRoute, Router } from "@angular/router"; +import { map, Subject } from "rxjs"; +import { ActivatedRoute, Router, RouterLinkWithHref } from "@angular/router"; import { ProblemService } from "@/services/problem.service"; import { ParseService } from "@/services/parse.service"; import { FontAwesomeModule } from "@fortawesome/angular-fontawesome"; +import { ToastService } from "@/services/toast.service"; export type ParseInputForm = FormGroup<{ + id: FormControl; premises: FormArray>; hypothesis: FormControl; kbItems: FormArray; }>; type KnowledgeBaseItemsForm = FormGroup<{ + id: FormControl; entity1: FormControl; relationship: FormControl; entity2: FormControl; @@ -47,7 +47,8 @@ export type ParseInput = ReturnType; FormsModule, ReactiveFormsModule, ProblemDetailsComponent, - FontAwesomeModule + FontAwesomeModule, + RouterLinkWithHref ], templateUrl: "./annotation-input.component.html", styleUrl: "./annotation-input.component.scss", @@ -58,13 +59,25 @@ export class AnnotationInputComponent implements OnInit { private destroyRef = inject(DestroyRef); private problemService = inject(ProblemService); private parseService = inject(ParseService); + private toastService = inject(ToastService); public form: ParseInputForm | null = null; - public problem: ProblemResponse | null = null; + + public problem$ = this.problemService.problem$; public submit$ = new Subject(); + public faCheck = faCheck; + public faTree = faTree; + public faFloppyDisk = faFloppyDisk; public faExclamationCircle = faExclamationCircle; + public faTrash = faTrash; + public faWrench = faWrench; + + public appMode$ = this.problemService.appMode$; + public isUserProblem$ = this.problem$.pipe( + map(problem => problem?.dataset === Dataset.USER) + ); ngOnInit(): void { this.problemService.problem$ @@ -73,11 +86,30 @@ export class AnnotationInputComponent implements OnInit { // Navigate away if the backend provides a new Problem ID. this.navigateToNewProblem(problem); - // Otherwise, update local state and form. - this.problem = problem; + // Otherwise, update local form. this.form = problem ? this.buildForm(problem) : null; }); + this.problemService.saveProblem$.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe((response) => { + if (response.error) { + this.toastService.show({ + header: $localize`Error`, + body: $localize`There was an error saving the problem: ${response.error}`, + type: 'danger' + }); + return; + } + + this.toastService.show({ + header: $localize`Problem saved`, + body: $localize`Problem successfully saved to database.`, + type: 'success' + }); + this.router.navigate(["/", "annotate", response.id]); + }); + // Subscription needed to ensure a request is actually made. // TODO: replace this with actual parse results. this.parseService.parse$ @@ -85,24 +117,30 @@ export class AnnotationInputComponent implements OnInit { .subscribe((response) => { console.log("Parse response:", response); }); + } - // Listen to route changes only after subscribing to ProblemService.problem$. - combineLatest([ - this.route.paramMap, - this.route.queryParamMap]) - .pipe( - takeUntilDestroyed(this.destroyRef) - ) - .subscribe(([params, queryParams]) => { - this.problemService.allParams$.next({ params, queryParams }); - }); + public startParse(): void { + if (!this.form || this.form.invalid) { + return; + } + const input = this.form.getRawValue(); + this.parseService.submit$.next(input); + } + + public saveProblem(): void { + this.form?.markAllAsTouched(); + if (!this.form || this.form.invalid) { + return; + } + const input = this.form.getRawValue(); + this.problemService.submit$.next(input); } - private navigateToNewProblem(problem: ProblemResponse | null): void { - if (!problem?.problem) { + private navigateToNewProblem(problem: Problem | null): void { + if (!problem || !problem.id) { return; } - const incomingProblemId = problem?.id?.toString(); + const incomingProblemId = problem.id.toString(); const currentProblemId = this.route.snapshot.paramMap.get("problemId"); if (incomingProblemId !== currentProblemId) { @@ -112,11 +150,16 @@ export class AnnotationInputComponent implements OnInit { } } - private buildForm(response: ProblemResponse): ParseInputForm { - const premises = response.problem?.premises || []; - const hypothesis = response.problem?.hypothesis || ""; + private buildForm(problem: Problem): ParseInputForm { + const id = problem.id; + const premises = problem.premises || []; + const hypothesis = problem.hypothesis || ""; + const kbItems = this.buildKbForms(problem.kbItems); return new FormGroup({ + id: new FormControl(id, { + nonNullable: true + }), premises: new FormArray( premises.map( (premise) => @@ -130,7 +173,23 @@ export class AnnotationInputComponent implements OnInit { validators: [Validators.required], nonNullable: true, }), - kbItems: new FormArray([]), + kbItems: new FormArray(kbItems), }); } + private buildKbForms(inputKbItems: Problem['kbItems']): KnowledgeBaseItemsForm[] { + return inputKbItems.map(item => new FormGroup({ + id: new FormControl(item.id, { + nonNullable: true + }), + entity1: new FormControl(item.entity1, { + nonNullable: true + }), + entity2: new FormControl(item.entity2, { + nonNullable: true + }), + relationship: new FormControl(item.relationship, { + nonNullable: true + }) + })); + } } diff --git a/frontend/src/app/annotate/annotation-input/knowledge-base-form/knowledge-base-form.component.html b/frontend/src/app/annotate/annotation-input/knowledge-base-form/knowledge-base-form.component.html index ec6d7f5..84b2095 100644 --- a/frontend/src/app/annotate/annotation-input/knowledge-base-form/knowledge-base-form.component.html +++ b/frontend/src/app/annotate/annotation-input/knowledge-base-form/knowledge-base-form.component.html @@ -1,4 +1,5 @@ @let kbControls = form().controls.kbItems.controls; +@let appMode = appMode$ | async;
@@ -6,6 +7,7 @@ class="form-label h6 fw-bold d-flex align-items-center justify-content-between" > Knowledge base items + @if (appMode === 'add' || appMode === 'edit') { + }
    - @for (kbItem of kbControls; let i = $index; track $index) { + @for (kbItem of kbControls; let i = $index; track `${i}-${kbItem.value.entity1}-${kbItem.value.relationship}-${kbItem.value.entity2}`) {
  • + @if (appMode === 'add' || appMode === 'edit') {
    + } @else { +

    + "{{ kbItem.controls.entity1.value }}" + {{ + getRelationshipTypeName(kbItem.controls.relationship.value) + }} + "{{ kbItem.controls.entity2.value }}" +

    + }
  • } @empty {
  • diff --git a/frontend/src/app/annotate/annotation-input/knowledge-base-form/knowledge-base-form.component.spec.ts b/frontend/src/app/annotate/annotation-input/knowledge-base-form/knowledge-base-form.component.spec.ts index 0c0da9c..7f1431b 100644 --- a/frontend/src/app/annotate/annotation-input/knowledge-base-form/knowledge-base-form.component.spec.ts +++ b/frontend/src/app/annotate/annotation-input/knowledge-base-form/knowledge-base-form.component.spec.ts @@ -2,6 +2,7 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { KnowledgeBaseFormComponent } from "./knowledge-base-form.component"; import { FormArray, FormGroup } from "@angular/forms"; +import { provideHttpClient } from "@angular/common/http"; describe("KnowledgeBaseFormComponent", () => { let component: KnowledgeBaseFormComponent; @@ -10,6 +11,9 @@ describe("KnowledgeBaseFormComponent", () => { beforeEach(async () => { await TestBed.configureTestingModule({ imports: [KnowledgeBaseFormComponent], + providers: [ + provideHttpClient(), + ] }).compileComponents(); fixture = TestBed.createComponent(KnowledgeBaseFormComponent); diff --git a/frontend/src/app/annotate/annotation-input/knowledge-base-form/knowledge-base-form.component.ts b/frontend/src/app/annotate/annotation-input/knowledge-base-form/knowledge-base-form.component.ts index 2d953ec..7d16065 100644 --- a/frontend/src/app/annotate/annotation-input/knowledge-base-form/knowledge-base-form.component.ts +++ b/frontend/src/app/annotate/annotation-input/knowledge-base-form/knowledge-base-form.component.ts @@ -1,23 +1,19 @@ -import { Component, input } from "@angular/core"; +import { Component, inject, input } from "@angular/core"; import { FormGroup, Validators, FormControl } from "@angular/forms"; import { CommonModule } from "@angular/common"; import { ReactiveFormsModule } from "@angular/forms"; import { FontAwesomeModule } from "@fortawesome/angular-fontawesome"; import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; import { ParseInputForm } from "../annotation-input.component"; +import { KnowledgeBaseRelationship } from "@/types"; +import { ProblemService } from "@/services/problem.service"; -export enum KnowledgeBaseRelationship { - EQUAL = "EQUAL", - NOT_EQUAL = "NOT_EQUAL", - SUBSET = "SUBSET", - SUPERSET = "SUPERSET", -} const relationshipDisplayMapping: Record = { - EQUAL: "is equal to", - NOT_EQUAL: "is not equal to", - SUBSET: "is a subset of", - SUPERSET: "is a superset of", + equal: "is equal to", + not_equal: "is not equal to", + subset: "is a subset of", + superset: "is a superset of", }; @Component({ @@ -28,6 +24,8 @@ const relationshipDisplayMapping: Record = { styleUrls: ["./knowledge-base-form.component.scss"], }) export class KnowledgeBaseFormComponent { + private problemService = inject(ProblemService); + public form = input.required(); public relationshipTypes = Object.values(KnowledgeBaseRelationship); @@ -35,8 +33,13 @@ export class KnowledgeBaseFormComponent { public faPlus = faPlus; public faTrash = faTrash; + public appMode$ = this.problemService.appMode$; + public addKnowledgeBaseItem(): void { const newItem = new FormGroup({ + id: new FormControl(null, { + nonNullable: true + }), entity1: new FormControl("", { validators: [Validators.required], nonNullable: true, diff --git a/frontend/src/app/annotate/annotation-input/premises-form/premises-form.component.html b/frontend/src/app/annotate/annotation-input/premises-form/premises-form.component.html index 25c6631..2f91624 100644 --- a/frontend/src/app/annotate/annotation-input/premises-form/premises-form.component.html +++ b/frontend/src/app/annotate/annotation-input/premises-form/premises-form.component.html @@ -1,4 +1,6 @@ @let premiseControls = form().controls.premises.controls; +@let appMode = appMode$ | async; +@let editingOrAdding = appMode === 'add' || appMode === 'edit';
    @@ -6,6 +8,7 @@ class="form-label h6 fw-bold d-flex align-items-center justify-content-between" > Premises + @if (editingOrAdding) { + }
      - @for (control of premiseControls; let i = $index; - track $index) { + @for (control of premiseControls; let i = $index; track + `${i}-${control.value}`) {
    • + @if (editingOrAdding) { + }
    • } @empty { -
    • No premises defined for this problem.
    • +
    • + No premises defined for this problem. +
    • }
    @@ -65,8 +77,17 @@ +

    + Each problem must have a hypothesis. +

diff --git a/frontend/src/app/annotate/annotation-input/premises-form/premises-form.component.ts b/frontend/src/app/annotate/annotation-input/premises-form/premises-form.component.ts index c5781cd..a1f700b 100644 --- a/frontend/src/app/annotate/annotation-input/premises-form/premises-form.component.ts +++ b/frontend/src/app/annotate/annotation-input/premises-form/premises-form.component.ts @@ -1,9 +1,10 @@ -import { Component, input } from "@angular/core"; +import { Component, inject, input } from "@angular/core"; import { ReactiveFormsModule, FormControl, Validators } from "@angular/forms"; import { CommonModule } from "@angular/common"; import { FontAwesomeModule } from "@fortawesome/angular-fontawesome"; import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; import { ParseInputForm } from "../annotation-input.component"; +import { ProblemService } from "@/services/problem.service"; export interface Premises { premises: string[]; @@ -18,11 +19,15 @@ export interface Premises { styleUrl: "./premises-form.component.scss", }) export class PremisesFormComponent { + private problemService = inject(ProblemService); + public form = input.required(); public faPlus = faPlus; public faTrash = faTrash; + public appMode$ = this.problemService.appMode$; + public addPremise(value: string = ""): void { const premisesArray = this.form().controls.premises; premisesArray.push( diff --git a/frontend/src/app/annotate/annotation-input/problem-details/problem-details.component.html b/frontend/src/app/annotate/annotation-input/problem-details/problem-details.component.html index cb3910d..e5bf8c5 100644 --- a/frontend/src/app/annotate/annotation-input/problem-details/problem-details.component.html +++ b/frontend/src/app/annotate/annotation-input/problem-details/problem-details.component.html @@ -1,46 +1,46 @@ @if (problemDetails(); as details) { -
-
- Entailment label: - -
- - - - - - - - - - - - @if (sectionString()) { - - - - - } - @if (details.comment) { - - - - - } - -
ID:{{ details.problemId }}
Dataset:{{ datasetLabels[details.dataset] }}
Section:{{ sectionString() }}
- Comment: - - {{ details.comment }} -
-
+
+ + + + + + + + + + + + + + + @if (sectionString()) { + + + + + } @if (details.comment) { + + + + + } + +
ID:{{ details.problemId }}
Dataset:{{ datasetLabels[details.dataset] }}
Entailment label: + +
Section:{{ sectionString() }}
+ Comment: + + {{ details.comment }} +
+
} diff --git a/frontend/src/app/annotate/annotation-input/problem-details/problem-details.component.scss b/frontend/src/app/annotate/annotation-input/problem-details/problem-details.component.scss index dd4f874..9f33078 100644 --- a/frontend/src/app/annotate/annotation-input/problem-details/problem-details.component.scss +++ b/frontend/src/app/annotate/annotation-input/problem-details/problem-details.component.scss @@ -1,15 +1,3 @@ -.problem-detail-container { - position: relative; -} - -.entailment-label-container { - position: absolute; - top: 1em; - right: 1em; - display: flex; - gap: 0.5em; -} - .table { td:first-child { width: 8em; diff --git a/frontend/src/app/annotate/annotation-input/problem-details/problem-details.component.spec.ts b/frontend/src/app/annotate/annotation-input/problem-details/problem-details.component.spec.ts index 0e010ca..f135997 100644 --- a/frontend/src/app/annotate/annotation-input/problem-details/problem-details.component.spec.ts +++ b/frontend/src/app/annotate/annotation-input/problem-details/problem-details.component.spec.ts @@ -1,7 +1,22 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { ProblemDetailsComponent } from "./problem-details.component"; -import { Dataset } from "../../../types"; +import { Dataset, EntailmentLabel, Problem } from "../../../types"; + +const createMockProblem = ( + id: number, + dataset: Dataset, + entailmentLabel: EntailmentLabel, + extraData: any = {}, +): Problem => ({ + id, + dataset, + entailmentLabel, + premises: ["premise"], + hypothesis: "hypothesis", + kbItems: [], + extraData, +}); describe("ProblemDetailsComponent", () => { let component: ProblemDetailsComponent; @@ -16,9 +31,7 @@ describe("ProblemDetailsComponent", () => { component = fixture.componentInstance; const componentRef = fixture.componentRef; componentRef.setInput("problem", { - problem: { - id: 1 - }, + id: "1", dataset: Dataset.SICK, }); fixture.detectChanges(); @@ -27,4 +40,89 @@ describe("ProblemDetailsComponent", () => { it("should create", () => { expect(component).toBeTruthy(); }); + + describe("with SICK dataset problem", () => { + beforeEach(() => { + const problem = createMockProblem( + 1, + Dataset.SICK, + EntailmentLabel.ENTAILMENT, + ); + fixture.componentRef.setInput("problem", problem); + fixture.detectChanges(); + }); + + it("should extract correct problem details", () => { + expect(component.problemDetails()).toEqual({ + problemId: "1", + dataset: Dataset.SICK, + entailmentLabel: EntailmentLabel.ENTAILMENT, + section: null, + subsection: null, + comment: null, + }); + }); + + it("should compute sectionString as null", () => { + expect(component.sectionString()).toBeNull(); + }); + }); + + describe("with FRACAS dataset problem", () => { + beforeEach(() => { + const problem = createMockProblem( + 2, + Dataset.FRACAS, + EntailmentLabel.CONTRADICTION, + { + sectionName: "Quantifiers", + subsectionName: "Some", + note: "A test note", + }, + ); + fixture.componentRef.setInput("problem", problem); + fixture.detectChanges(); + }); + + it("should extract correct problem details", () => { + expect(component.problemDetails()).toEqual({ + problemId: "2", + dataset: Dataset.FRACAS, + entailmentLabel: EntailmentLabel.CONTRADICTION, + section: "Quantifiers", + subsection: "Some", + comment: "A test note", + }); + }); + + it("should compute sectionString with section and subsection", () => { + expect(component.sectionString()).toBe("Quantifiers | Some"); + }); + }); + + describe("sectionString computation", () => { + it("should show only section when subsection is null", () => { + const problem = createMockProblem( + 5, + Dataset.FRACAS, + EntailmentLabel.ENTAILMENT, + { sectionName: "SectionOnly" }, + ); + fixture.componentRef.setInput("problem", problem); + fixture.detectChanges(); + expect(component.sectionString()).toBe("SectionOnly"); + }); + + it("should show only subsection when section is null", () => { + const problem = createMockProblem( + 6, + Dataset.FRACAS, + EntailmentLabel.ENTAILMENT, + { subsectionName: "SubsectionOnly" }, + ); + fixture.componentRef.setInput("problem", problem); + fixture.detectChanges(); + expect(component.sectionString()).toBe("SubsectionOnly"); + }); + }); }); diff --git a/frontend/src/app/annotate/annotation-input/problem-details/problem-details.component.ts b/frontend/src/app/annotate/annotation-input/problem-details/problem-details.component.ts index 248eb1b..2630d79 100644 --- a/frontend/src/app/annotate/annotation-input/problem-details/problem-details.component.ts +++ b/frontend/src/app/annotate/annotation-input/problem-details/problem-details.component.ts @@ -1,5 +1,5 @@ import { Component, computed, input } from "@angular/core"; -import { Dataset, EntailmentLabel, ProblemResponse } from "../../../types"; +import { Dataset, EntailmentLabel, Problem } from "../../../types"; import { EntailmentLabelBadgeComponent } from "./entailment-label-badge/entailment-label-badge.component"; import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeModule } from "@fortawesome/angular-fontawesome"; @@ -27,7 +27,7 @@ export interface ProblemDetails { styleUrl: "./problem-details.component.scss", }) export class ProblemDetailsComponent { - public readonly problem = input.required(); + public readonly problem = input.required(); public problemDetails = computed(() => { const problem = this.problem(); @@ -57,23 +57,17 @@ export class ProblemDetailsComponent { return null; }); - private extractDetails( - response: ProblemResponse | null, - ): ProblemDetails | null { - if (!response?.problem) { - return null; - } - + private extractDetails(problem: Problem): ProblemDetails | null { const shared: Pick< ProblemDetails, "problemId" | "dataset" | "entailmentLabel" > = { - problemId: response.problem.id.toString(), - dataset: response.problem.dataset, - entailmentLabel: response.problem.entailmentLabel, + problemId: problem.id?.toString() ?? $localize`new`, + dataset: problem.dataset, + entailmentLabel: problem.entailmentLabel, }; - switch (response.problem.dataset) { + switch (problem.dataset) { case Dataset.SICK: return { ...shared, @@ -84,9 +78,9 @@ export class ProblemDetailsComponent { case Dataset.FRACAS: return { ...shared, - section: response.problem.extraData.sectionName, - subsection: response.problem.extraData.subsectionName, - comment: response.problem.extraData.note || null, + section: problem.extraData.sectionName, + subsection: problem.extraData.subsectionName, + comment: problem.extraData.note || null, }; case Dataset.SNLI: return { diff --git a/frontend/src/app/annotate/navigator/navigator.component.html b/frontend/src/app/annotate/navigator/navigator.component.html index 6b7e350..0b28368 100644 --- a/frontend/src/app/annotate/navigator/navigator.component.html +++ b/frontend/src/app/annotate/navigator/navigator.component.html @@ -1,13 +1,13 @@ -@let problem = problem$ | async; +@let problemResponse = problemResponse$ | async; diff --git a/frontend/src/app/annotate/navigator/navigator.component.ts b/frontend/src/app/annotate/navigator/navigator.component.ts index e2ae8a1..6ed1e45 100644 --- a/frontend/src/app/annotate/navigator/navigator.component.ts +++ b/frontend/src/app/annotate/navigator/navigator.component.ts @@ -8,34 +8,24 @@ import { faShuffle, } from "@fortawesome/free-solid-svg-icons"; import { CommonModule } from "@angular/common"; -import { Router } from "@angular/router"; +import { RouterLinkWithHref } from "@angular/router"; import { ProblemService } from "@/services/problem.service"; @Component({ selector: "la-navigator", standalone: true, - imports: [FontAwesomeModule, CommonModule], + imports: [FontAwesomeModule, CommonModule, RouterLinkWithHref], templateUrl: "./navigator.component.html", styleUrl: "./navigator.component.scss", }) export class NavigatorComponent { - private router = inject(Router); private problemService = inject(ProblemService); - public problem$ = this.problemService.problem$; + public problemResponse$ = this.problemService.problemResponse$; public faAnglesLeft = faAnglesLeft; public faAnglesRight = faAnglesRight; public faAngleLeft = faAngleLeft; public faAngleRight = faAngleRight; public faShuffle = faShuffle; - - public navigateToProblem(id: string | null | undefined): void { - if (!id) { - return; - } - this.router.navigate(["/annotate", id], { - queryParamsHandling: "preserve" - }); - } } diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index 49cc382..5373800 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -41,6 +41,11 @@ const routes: Routes = [ canActivate: [LoggedOnGuard], component: UserSettingsComponent, }, + { + path: "annotate/:problemId/edit", + canActivate: [LoggedOnGuard], + component: AnnotateComponent, + }, { path: "annotate/:problemId", canActivate: [LoggedOnGuard], diff --git a/frontend/src/app/menu/menu.component.html b/frontend/src/app/menu/menu.component.html index ac0eaab..c775c34 100644 --- a/frontend/src/app/menu/menu.component.html +++ b/frontend/src/app/menu/menu.component.html @@ -1,5 +1,5 @@ @let showAnnotate = (loggedIn$ | async); -@let firstProblemId = (getFirstProblemId$ | async); +@let firstProblemId = (firstProblemId$ | async);