diff --git a/backend/annotation/serializers_test.py b/backend/annotation/serializers_test.py index de578b5e..30cdd8be 100644 --- a/backend/annotation/serializers_test.py +++ b/backend/annotation/serializers_test.py @@ -1,7 +1,6 @@ import pytest from typing import Any -from django.utils import timezone from django.contrib.auth.models import Permission from rest_framework.test import APIRequestFactory @@ -11,7 +10,6 @@ LabelAnnotationSerializer, SaveLabelsInputSerializer, ) -from problem.serializers import ProblemSerializer @pytest.mark.django_db diff --git a/backend/problem/conftest.py b/backend/problem/conftest.py new file mode 100644 index 00000000..11c844de --- /dev/null +++ b/backend/problem/conftest.py @@ -0,0 +1,35 @@ +import pytest + +from problem.models import Problem, Sentence + + +@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 diff --git a/backend/problem/migrations/0010_problem_gold.py b/backend/problem/migrations/0010_problem_gold.py new file mode 100644 index 00000000..d2e58dcf --- /dev/null +++ b/backend/problem/migrations/0010_problem_gold.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("problem", "0009_problem_hidden"), + ] + + operations = [ + migrations.AddField( + model_name="problem", + name="gold", + field=models.BooleanField(default=False), + ), + ] diff --git a/backend/problem/models.py b/backend/problem/models.py index ba8bc247..5a3c1dc0 100644 --- a/backend/problem/models.py +++ b/backend/problem/models.py @@ -21,6 +21,11 @@ class EntailmentLabel(models.TextChoices): CONTRADICTION = "contradiction", "Contradiction" UNKNOWN = "unknown", "Unknown" + class Status(models.TextChoices): + GOLD = "gold", "Gold" + SILVER = "silver", "Silver" + BRONZE = "bronze", "Bronze" + dataset = models.CharField( max_length=255, choices=Dataset.choices, @@ -55,6 +60,8 @@ class EntailmentLabel(models.TextChoices): hidden = models.BooleanField(default=False) + gold = models.BooleanField(default=False) + extra_data = models.JSONField() class Meta: @@ -76,3 +83,19 @@ def get_index(self, qs: QuerySet) -> int | None: except Exception as e: logger.exception(f"Error getting index for problem {self.pk}: {e}") return None + + @property + def status(self) -> "Problem.Status": + """ + Returns the computed status of this problem: + - GOLD if the problem is marked as gold. + - SILVER if not gold but has active annotations (KB items or labels). + - BRONZE otherwise (no annotations). + """ + if self.gold: + return Problem.Status.GOLD + has_annotations = ( + self.knowledgebaseannotations.filter(removed_at__isnull=True).exists() + or self.labelannotations.filter(removed_at__isnull=True).exists() + ) + return Problem.Status.SILVER if has_annotations else Problem.Status.BRONZE diff --git a/backend/problem/models_test.py b/backend/problem/models_test.py new file mode 100644 index 00000000..11f97240 --- /dev/null +++ b/backend/problem/models_test.py @@ -0,0 +1,87 @@ +import pytest + +from annotation.models import KnowledgeBaseAnnotation, LabelAnnotation +from problem.models import Problem + + +@pytest.fixture +def kb_annotation(db, annotator_session, non_user_problem): + return KnowledgeBaseAnnotation.objects.create( + problem=non_user_problem, + entity1="dog", + entity2="canine", + relationship=KnowledgeBaseAnnotation.Relationship.EQUAL, + session=annotator_session, + created_by=annotator_session.user, + ) + +@pytest.mark.django_db +def test_status_bronze_no_annotations(db, non_user_problem): + """ + A problem with no annotations and gold=False is bronze. + + This also functions as an initial assumption check for the tests below. + """ + assert non_user_problem.status == Problem.Status.BRONZE + + +@pytest.mark.django_db +def test_status_gold_without_annotation(db, non_user_problem): + """A problem with gold=True is gold without annotations.""" + non_user_problem.gold = True + non_user_problem.save() + assert non_user_problem.status == Problem.Status.GOLD + +@pytest.mark.django_db +def test_status_gold_with_annotation(db, non_user_problem, kb_annotation): + """A problem with gold=True is gold even with annotations.""" + non_user_problem.gold = True + non_user_problem.save() + assert non_user_problem.status == Problem.Status.GOLD + + +@pytest.mark.django_db +def test_status_silver_with_kb_annotation(db, non_user_problem, kb_annotation): + """A problem with an active KB annotation and gold=False is silver.""" + assert non_user_problem.status == Problem.Status.SILVER + +@pytest.mark.django_db +def test_status_silver_with_label_annotation(db, non_user_problem, annotator_session, sample_label): + """A problem with an active label annotation and gold=False is silver.""" + LabelAnnotation.objects.create( + problem=non_user_problem, + label=sample_label, + session=annotator_session, + created_by=annotator_session.user, + ) + assert non_user_problem.status == Problem.Status.SILVER + + +@pytest.mark.django_db +def test_status_bronze_when_all_annotations_removed(db, non_user_problem, annotator_session): + """A problem whose only annotation is removed reverts to bronze.""" + from django.utils import timezone + + kb = KnowledgeBaseAnnotation.objects.create( + problem=non_user_problem, + entity1="dog", + entity2="canine", + relationship=KnowledgeBaseAnnotation.Relationship.EQUAL, + session=annotator_session, + created_by=annotator_session.user, + ) + kb.removed_at = timezone.now() + kb.removed_by = annotator_session.user + kb.save() + + assert non_user_problem.status == Problem.Status.BRONZE + + +@pytest.mark.django_db +def test_status_serialized(db, non_user_problem): + """The status field is correctly serialized.""" + from problem.serializers import ProblemSerializer + + serializer = ProblemSerializer(non_user_problem) + assert serializer.data["status"] == Problem.Status.BRONZE + assert serializer.data["gold"] is False diff --git a/backend/problem/problem_details.py b/backend/problem/problem_details.py index 7cf74c9e..09077f4b 100644 --- a/backend/problem/problem_details.py +++ b/backend/problem/problem_details.py @@ -1,5 +1,6 @@ from dataclasses import dataclass +from django.db.models import Exists, OuterRef from django.http import QueryDict from django.db.models import QuerySet, Q @@ -59,7 +60,6 @@ def get_filters(query_params: QueryDict, user: User | None = None) -> Q | None: """ dataset = query_params.get("dataset") entailment_label = query_params.get("entailmentLabel") - gold = query_params.get("gold") text = query_params.get("text") hidden = query_params.get("hidden", None) @@ -70,9 +70,6 @@ def get_filters(query_params: QueryDict, user: User | None = None) -> Q | None: filters &= Q(dataset=dataset) if entailment_label: filters &= Q(entailment_label=entailment_label) - 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) @@ -84,3 +81,38 @@ def get_filters(query_params: QueryDict, user: User | None = None) -> Q | None: filters &= Q(hidden=hidden.lower() == 'true') return filters + + +def apply_status_filter( + qs: QuerySet[Problem], query_params: QueryDict +) -> QuerySet[Problem]: + """ + Applies a status filter to the queryset based on the 'status' query parameter. + Returns the queryset unchanged if no valid status is provided. + """ + from annotation.models import KnowledgeBaseAnnotation, LabelAnnotation + + status_param = query_params.get("status") + if not status_param: + return qs + + has_active_kb = Exists( + KnowledgeBaseAnnotation.objects.filter( + problem=OuterRef("pk"), removed_at__isnull=True + ) + ) + has_active_label = Exists( + LabelAnnotation.objects.filter( + problem=OuterRef("pk"), removed_at__isnull=True + ) + ) + + match status_param: + case Problem.Status.GOLD: + return qs.filter(gold=True) + case Problem.Status.SILVER: + return qs.filter(gold=False).filter(has_active_kb | has_active_label) + case Problem.Status.BRONZE: + return qs.filter(gold=False).exclude(has_active_kb | has_active_label) + case _: + return qs diff --git a/backend/problem/serializers.py b/backend/problem/serializers.py index 8e58a837..4cd3f298 100644 --- a/backend/problem/serializers.py +++ b/backend/problem/serializers.py @@ -21,6 +21,7 @@ class ProblemSerializer(serializers.ModelSerializer): hypothesis = serializers.SerializerMethodField() entailmentLabel = serializers.CharField(source="entailment_label") extraData = serializers.SerializerMethodField() + status = serializers.CharField(read_only=True) class Meta: model = Problem @@ -33,6 +34,8 @@ class Meta: "extraData", "base", "hidden", + "gold", + "status", ] def get_premises(self, problem: Problem): diff --git a/backend/problem/serializers_test.py b/backend/problem/serializers_test.py index d891c97e..aa9fd07c 100644 --- a/backend/problem/serializers_test.py +++ b/backend/problem/serializers_test.py @@ -3,39 +3,6 @@ from annotation.models import KnowledgeBaseAnnotation from .serializers import ProblemInputSerializer -from .models import Problem, Sentence - - -@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.mark.django_db diff --git a/backend/problem/views/problem.py b/backend/problem/views/problem.py index 99d70882..225b8a8d 100644 --- a/backend/problem/views/problem.py +++ b/backend/problem/views/problem.py @@ -15,6 +15,7 @@ from problem.problem_details import ( get_filters, + apply_status_filter, get_related_problem_ids, ) from problem.models import Problem @@ -45,6 +46,11 @@ def has_permission(self, request, view): return super().has_permission(request, view) and request.user.can_change_problem_visibility +class ChangeProblemStatusPermission(IsAuthenticated): + def has_permission(self, request, view): + return super().has_permission(request, view) and request.user.can_change_problem_status + + class ProblemView(ModelViewSet): queryset = Problem.objects.all() serializer_class = ProblemSerializer @@ -56,6 +62,8 @@ def get_permissions(self): return [EditProblemPermission()] if self.action == "set_visibility": return [ChangeProblemVisibilityPermission()] + if self.action == "set_status": + return [ChangeProblemStatusPermission()] return [IsAuthenticatedOrReadOnly()] def list(self, request: Request) -> Response: @@ -69,9 +77,28 @@ def list(self, request: Request) -> Response: if filters is not None: qs = qs.filter(filters) + qs = apply_status_filter(qs, request.query_params) + serializer = self.get_serializer(qs, many=True) return Response(serializer.data, status=HTTP_200_OK) + @action(detail=True, methods=["post"], url_path="set-status") + def set_status(self, request: Request, pk: int) -> Response: + """ + Toggles the gold status of a Problem. + Expects a JSON body with a boolean 'gold' field. + """ + problem = get_object_or_404(Problem, id=pk) + gold = request.data.get("gold") + if not isinstance(gold, bool): + return Response( + {"detail": "'gold' must be a boolean."}, + status=HTTP_400_BAD_REQUEST, + ) + problem.gold = gold + problem.save(update_fields=["gold"]) + return Response({"gold": problem.gold, "status": problem.status}, status=HTTP_200_OK) + @action(detail=False, methods=["get"], url_path="first") def first(self, request: Request) -> Response: """ @@ -115,6 +142,8 @@ def _get_problem_response(self, request: Request, pk: int | None) -> Response: if filters is not None: qs = qs.filter(filters).distinct() + qs = apply_status_filter(qs, request.query_params) + problem = None if pk is not None: try: diff --git a/backend/user/models.py b/backend/user/models.py index 6b2df2b3..1722b7c8 100644 --- a/backend/user/models.py +++ b/backend/user/models.py @@ -79,6 +79,13 @@ def can_change_problem_visibility(self) -> bool: """ return self.has_perm("problem.change_problem_visibility") + @property + def can_change_problem_status(self) -> bool: + """ + Determines whether the user can change problem status (gold/ungold). + """ + return self.has_perm("problem.change_problem_status") + @property def can_edit_kb(self) -> bool: """ diff --git a/backend/user/serializers.py b/backend/user/serializers.py index 3f0fc062..e2bc906a 100644 --- a/backend/user/serializers.py +++ b/backend/user/serializers.py @@ -19,6 +19,9 @@ class CustomUserDetailsSerializer(UserDetailsSerializer): canChangeProblemVisibility = serializers.BooleanField( read_only=True, source="can_change_problem_visibility" ) + canChangeProblemStatus = serializers.BooleanField( + read_only=True, source="can_change_problem_status" + ) class Meta(UserDetailsSerializer.Meta): @@ -36,5 +39,6 @@ class Meta(UserDetailsSerializer.Meta): "canAddLabelAnnotations", "canCopyProblem", "canChangeProblemVisibility", + "canChangeProblemStatus", ) read_only_fields = ["isStaff", "id", "email"] diff --git a/backend/user/tests/test_user_views.py b/backend/user/tests/test_user_views.py index 4d3fdbc5..3f9c5b8e 100644 --- a/backend/user/tests/test_user_views.py +++ b/backend/user/tests/test_user_views.py @@ -18,6 +18,7 @@ def test_user_details(user_client, user_data): "canCopyProblem": False, "canAddLabelAnnotations": False, "canChangeProblemVisibility": False, + "canChangeProblemStatus": False, } 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 ee61737f..95470708 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 @@ -6,7 +6,7 @@ import { provideHttpClientTesting } from "@angular/common/http/testing"; import { provideHttpClient } from "@angular/common/http"; import { ActivatedRoute, Router } from "@angular/router"; import { of } from "rxjs"; -import { Dataset, KnowledgeBaseRelationship, Problem, EntailmentLabel } from "../../types"; +import { Dataset, KnowledgeBaseRelationship, Problem, EntailmentLabel, ProblemStatus } from "../../types"; describe("AnnotationInputComponent", () => { let component: AnnotationInputComponent; @@ -84,7 +84,9 @@ describe("AnnotationInputComponent", () => { ], labelAnnotations: [], dataset: Dataset.USER, - extraData: null + extraData: null, + gold: false, + status: ProblemStatus.BRONZE }; // Access private method. @@ -129,7 +131,9 @@ describe("AnnotationInputComponent", () => { kbAnnotations: [], labelAnnotations: [], dataset: Dataset.USER, - extraData: null + extraData: null, + gold: false, + status: ProblemStatus.BRONZE }; const form = component['buildForm'](mockProblem); @@ -153,6 +157,8 @@ describe("AnnotationInputComponent", () => { extraData: null, kbAnnotations: [], labelAnnotations: [], + gold: false, + status: ProblemStatus.BRONZE }; const form = component['buildForm'](mockProblem); @@ -177,6 +183,8 @@ describe("AnnotationInputComponent", () => { extraData: null, kbAnnotations: [], labelAnnotations: [], + gold: false, + status: ProblemStatus.BRONZE }; component['navigateToNewProblem'](mockProblem); @@ -199,6 +207,8 @@ describe("AnnotationInputComponent", () => { extraData: null, kbAnnotations: [], labelAnnotations: [], + gold: false, + status: ProblemStatus.BRONZE }; component['navigateToNewProblem'](mockProblem); diff --git a/frontend/src/app/annotate/annotation-input/problem-details/gold-toggle/gold-toggle.component.html b/frontend/src/app/annotate/annotation-input/problem-details/gold-toggle/gold-toggle.component.html new file mode 100644 index 00000000..a710d1a0 --- /dev/null +++ b/frontend/src/app/annotate/annotation-input/problem-details/gold-toggle/gold-toggle.component.html @@ -0,0 +1,16 @@ +@let gold = problem().gold; + diff --git a/frontend/src/app/annotate/annotation-input/problem-details/gold-toggle/gold-toggle.component.scss b/frontend/src/app/annotate/annotation-input/problem-details/gold-toggle/gold-toggle.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/frontend/src/app/annotate/annotation-input/problem-details/gold-toggle/gold-toggle.component.ts b/frontend/src/app/annotate/annotation-input/problem-details/gold-toggle/gold-toggle.component.ts new file mode 100644 index 00000000..9128d160 --- /dev/null +++ b/frontend/src/app/annotate/annotation-input/problem-details/gold-toggle/gold-toggle.component.ts @@ -0,0 +1,26 @@ +import { ProblemService } from '@/services/problem.service'; +import { Problem } from '@/types'; +import { Component, inject, input } from '@angular/core'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { faMedal } from '@fortawesome/free-solid-svg-icons'; + +@Component({ + selector: 'la-gold-toggle', + imports: [FontAwesomeModule], + templateUrl: './gold-toggle.component.html', + styleUrl: './gold-toggle.component.scss' +}) +export class GoldToggleComponent { + public readonly problem = input.required(); + private problemService = inject(ProblemService); + + public faMedal = faMedal; + + public onToggleGold(): void { + const problem = this.problem(); + if (!problem?.id) { + return; + } + this.problemService.toggleGold$.next({ id: problem.id, gold: !problem.gold }); + } +} 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 7e8fa4e3..b576f343 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,11 +1,20 @@ @let appMode = appMode$ | async; @let currentUser = currentUser$ | async; +@let hidden = problem().hidden; +@let status = problem().status; @if (problemDetails(); as details) { -
- @if (currentUser?.canChangeProblemVisibility) { - + @if (hidden) { + } + +
@@ -72,5 +81,24 @@
+
+
+ + +
+ @if (currentUser?.canChangeProblemStatus) { + + } @if (currentUser?.canChangeProblemVisibility) { + + } +
} 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 dca1f970..64e6b648 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 @@ -4,3 +4,23 @@ color: var(--bs-secondary-color); } } + +@keyframes growIn { + from { + opacity: 0; + max-height: 0; + padding-top: 0; + padding-bottom: 0; + margin-bottom: 0; + overflow: hidden; + } + to { + opacity: 1; + max-height: 200px; + overflow: hidden; + } +} + +.alert.alert-info { + animation: growIn 0.3s ease-out; +} 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 26cf4327..463673bb 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,18 +1,20 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { ProblemDetailsComponent } from "./problem-details.component"; -import { Dataset, EntailmentLabel, Problem } from "../../../types"; +import { Dataset, EntailmentLabel, Problem, ProblemStatus } from "@/types"; import { provideHttpClient } from "@angular/common/http"; +import { NgbActiveModal } from "@ng-bootstrap/ng-bootstrap"; const createMockProblem = ( id: number, dataset: Dataset, entailmentLabel: EntailmentLabel, + hidden = false, extraData: any = {}, ): Problem => ({ id, base: null, - hidden: false, + hidden, dataset, entailmentLabel, premises: ["premise"], @@ -20,16 +22,28 @@ const createMockProblem = ( extraData, kbAnnotations: [], labelAnnotations: [], + gold: false, + status: ProblemStatus.BRONZE }); describe("ProblemDetailsComponent", () => { let component: ProblemDetailsComponent; let fixture: ComponentFixture; + let mockActiveModal: jasmine.SpyObj; beforeEach(async () => { + mockActiveModal = jasmine.createSpyObj("NgbActiveModal", [ + "close", + "dismiss", + ]); + + await TestBed.configureTestingModule({ imports: [ProblemDetailsComponent], - providers: [provideHttpClient()] + providers: [ + provideHttpClient(), + { provide: NgbActiveModal, useValue: mockActiveModal } + ] }).compileComponents(); fixture = TestBed.createComponent(ProblemDetailsComponent); @@ -82,6 +96,7 @@ describe("ProblemDetailsComponent", () => { 2, Dataset.FRACAS, EntailmentLabel.CONTRADICTION, + false, { sectionName: "Quantifiers", subsectionName: "Some", @@ -116,6 +131,7 @@ describe("ProblemDetailsComponent", () => { 5, Dataset.FRACAS, EntailmentLabel.ENTAILMENT, + false, { sectionName: "SectionOnly" }, ); fixture.componentRef.setInput("problem", problem); @@ -128,6 +144,7 @@ describe("ProblemDetailsComponent", () => { 6, Dataset.FRACAS, EntailmentLabel.ENTAILMENT, + false, { subsectionName: "SubsectionOnly" }, ); fixture.componentRef.setInput("problem", problem); @@ -135,4 +152,23 @@ describe("ProblemDetailsComponent", () => { expect(component.sectionString()).toBe("SubsectionOnly"); }); }); + + it('should show the alert banner only when the problem is hidden', () => { + const getAlert = () => + fixture.nativeElement.querySelector('[role="alert"]'); + + expect(getAlert()).toBeNull(); + + const problem = createMockProblem( + 7, + Dataset.SNLI, + EntailmentLabel.NEUTRAL, + true + ); + + fixture.componentRef.setInput('problem', problem); + fixture.detectChanges(); + + expect(getAlert()).not.toBeNull(); + }); }); 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 afa0786f..d8df8946 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,9 +1,9 @@ import { Dataset, EntailmentLabel, LabelAnnotation, Problem } from "../../../types"; import { Component, computed, inject, input } from "@angular/core"; import { EntailmentLabelBadgeComponent } from "./entailment-label-badge/entailment-label-badge.component"; -import { faArrowUpRightFromSquare, faQuestionCircle } from "@fortawesome/free-solid-svg-icons"; +import { faArrowUpRightFromSquare, faCircleInfo, faEyeSlash, faQuestionCircle } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeModule } from "@fortawesome/angular-fontawesome"; -import { NgbTooltipModule } from "@ng-bootstrap/ng-bootstrap"; +import { NgbModal, NgbTooltipModule } from "@ng-bootstrap/ng-bootstrap"; import { datasetLabels } from "@/shared/displayTextMappings"; import { ProblemLabelsComponent } from "./problem-labels/problem-labels.component"; import { CommonModule } from "@angular/common"; @@ -11,6 +11,9 @@ import { RouterModule } from "@angular/router"; import { ProblemService } from "@/services/problem.service"; import { AuthService } from "@/services/auth.service"; import { VisibilityToggleComponent } from "./visibility-toggle/visibility-toggle.component"; +import { GoldToggleComponent } from "./gold-toggle/gold-toggle.component"; +import { StatusBadgeComponent } from "./status-badge/status-badge.component"; +import { StatusInfoModalComponent } from "@/annotate/search/status-info-modal/status-info-modal.component"; export interface ProblemDetails { problemId: string; @@ -33,7 +36,9 @@ export interface ProblemDetails { NgbTooltipModule, CommonModule, RouterModule, - VisibilityToggleComponent + VisibilityToggleComponent, + GoldToggleComponent, + StatusBadgeComponent, ], templateUrl: "./problem-details.component.html", styleUrl: "./problem-details.component.scss", @@ -42,6 +47,7 @@ export class ProblemDetailsComponent { public readonly problem = input.required(); private problemService = inject(ProblemService); private authService = inject(AuthService); + private modalService = inject(NgbModal); public appMode$ = this.problemService.appMode$; public currentUser$ = this.authService.currentUser$; @@ -55,8 +61,10 @@ export class ProblemDetailsComponent { }); public faQuestionCircle = faQuestionCircle; + public faEyeSlash = faEyeSlash; public faArrowUpRight = faArrowUpRightFromSquare; public datasetLabels = datasetLabels; + public faCircleInfo = faCircleInfo; public sectionString = computed(() => { const problemDetails = this.problemDetails(); @@ -75,6 +83,12 @@ export class ProblemDetailsComponent { return null; }); + public showStatusInfoModal(): void { + this.modalService.open(StatusInfoModalComponent, { + centered: true, + }); + } + private extractDetails(problem: Problem): ProblemDetails | null { const shared: Pick< ProblemDetails, diff --git a/frontend/src/app/annotate/annotation-input/problem-details/status-badge/status-badge.component.html b/frontend/src/app/annotate/annotation-input/problem-details/status-badge/status-badge.component.html new file mode 100644 index 00000000..bd6566eb --- /dev/null +++ b/frontend/src/app/annotate/annotation-input/problem-details/status-badge/status-badge.component.html @@ -0,0 +1,11 @@ + diff --git a/frontend/src/app/annotate/annotation-input/problem-details/status-badge/status-badge.component.scss b/frontend/src/app/annotate/annotation-input/problem-details/status-badge/status-badge.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/frontend/src/app/annotate/annotation-input/problem-details/status-badge/status-badge.component.spec.ts b/frontend/src/app/annotate/annotation-input/problem-details/status-badge/status-badge.component.spec.ts new file mode 100644 index 00000000..5477f9ed --- /dev/null +++ b/frontend/src/app/annotate/annotation-input/problem-details/status-badge/status-badge.component.spec.ts @@ -0,0 +1,26 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { StatusBadgeComponent } from './status-badge.component'; +import { ProblemStatus } from '@/types'; + +describe('StatusBadgeComponent', () => { + let component: StatusBadgeComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [StatusBadgeComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(StatusBadgeComponent); + component = fixture.componentInstance; + const componentRef = fixture.componentRef; + componentRef.setInput("status", ProblemStatus.BRONZE); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/annotate/annotation-input/problem-details/status-badge/status-badge.component.ts b/frontend/src/app/annotate/annotation-input/problem-details/status-badge/status-badge.component.ts new file mode 100644 index 00000000..323cd1b6 --- /dev/null +++ b/frontend/src/app/annotate/annotation-input/problem-details/status-badge/status-badge.component.ts @@ -0,0 +1,19 @@ +import { statusLabels } from '@/shared/displayTextMappings'; +import { ProblemStatus } from '@/types'; +import { Component, input } from '@angular/core'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { faMedal } from '@fortawesome/free-solid-svg-icons'; +import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; + +@Component({ + selector: 'la-status-badge', + imports: [FontAwesomeModule, NgbTooltipModule], + templateUrl: './status-badge.component.html', + styleUrl: './status-badge.component.scss' +}) +export class StatusBadgeComponent { + public readonly status = input.required(); + public statusLabels = statusLabels; + public faMedal = faMedal; + public ProblemStatus = ProblemStatus; +} diff --git a/frontend/src/app/annotate/annotation-input/problem-details/visibility-toggle/visibility-toggle.component.html b/frontend/src/app/annotate/annotation-input/problem-details/visibility-toggle/visibility-toggle.component.html index 32789d5c..0b1187c6 100644 --- a/frontend/src/app/annotate/annotation-input/problem-details/visibility-toggle/visibility-toggle.component.html +++ b/frontend/src/app/annotate/annotation-input/problem-details/visibility-toggle/visibility-toggle.component.html @@ -1,31 +1,21 @@ @let hidden = problem().hidden; -
- @if (hidden) { - - } - -
+ + diff --git a/frontend/src/app/annotate/annotation-input/problem-details/visibility-toggle/visibility-toggle.component.spec.ts b/frontend/src/app/annotate/annotation-input/problem-details/visibility-toggle/visibility-toggle.component.spec.ts index b2b5e8e4..f4b8300a 100644 --- a/frontend/src/app/annotate/annotation-input/problem-details/visibility-toggle/visibility-toggle.component.spec.ts +++ b/frontend/src/app/annotate/annotation-input/problem-details/visibility-toggle/visibility-toggle.component.spec.ts @@ -39,16 +39,4 @@ describe('VisibilityToggleComponent', () => { expect(emitted).toEqual([{ id: 123, hidden: true }]); }); - - it('should show the alert banner only when the problem is hidden', () => { - const getAlert = () => - fixture.nativeElement.querySelector('[role="alert"]'); - - expect(getAlert()).toBeNull(); - - fixture.componentRef.setInput('problem', { id: 123, hidden: true }); - fixture.detectChanges(); - - expect(getAlert()).not.toBeNull(); - }); }); diff --git a/frontend/src/app/annotate/search/search.component.html b/frontend/src/app/annotate/search/search.component.html index 55e50dcf..912e0cf3 100644 --- a/frontend/src/app/annotate/search/search.component.html +++ b/frontend/src/app/annotate/search/search.component.html @@ -1,5 +1,6 @@ @let loading = loading$ | async; -@let canChangeVisibility = canChangeVisibility$ | async; +@let canChangeVisibility = canChangeVisibility$ +| async;
@@ -40,18 +41,30 @@ [options]="entailmentLabelOptions" /> - +
+ + +
@if (canChangeVisibility) { - - + + }
diff --git a/frontend/src/app/annotate/search/search.component.spec.ts b/frontend/src/app/annotate/search/search.component.spec.ts index 43b05de0..ba8d041c 100644 --- a/frontend/src/app/annotate/search/search.component.spec.ts +++ b/frontend/src/app/annotate/search/search.component.spec.ts @@ -18,7 +18,7 @@ describe("SearchComponent", () => { provide: ActivatedRoute, useValue: { params: of({ problemId: "1" }), - queryParamMap: of({ dataset: null, entailmentLabel: null, gold: null, text: "" }), + queryParamMap: of({ dataset: null, entailmentLabel: null, status: null, text: "" }), } }] }).compileComponents(); diff --git a/frontend/src/app/annotate/search/search.component.ts b/frontend/src/app/annotate/search/search.component.ts index 15d69afc..d4a26313 100644 --- a/frontend/src/app/annotate/search/search.component.ts +++ b/frontend/src/app/annotate/search/search.component.ts @@ -1,4 +1,4 @@ -import { Dataset, EntailmentLabel } from "@/types"; +import { Dataset, EntailmentLabel, ProblemStatus } from "@/types"; import { CommonModule } from "@angular/common"; import { Component, DestroyRef, inject } from "@angular/core"; import { @@ -8,23 +8,24 @@ import { ReactiveFormsModule, } from "@angular/forms"; import { FontAwesomeModule } from "@fortawesome/angular-fontawesome"; -import { faSearch, faTimes } from "@fortawesome/free-solid-svg-icons"; -import { NgbDropdownModule } from "@ng-bootstrap/ng-bootstrap"; +import { faCircleInfo, faSearch, faTimes } from "@fortawesome/free-solid-svg-icons"; +import { NgbDropdownModule, NgbModal } from "@ng-bootstrap/ng-bootstrap"; import { BehaviorSubject, map } from "rxjs"; import { FilterSelectComponent, SelectOption, } from "./filter-select/filter-select.component"; -import { datasetLabels, entailmentLabels } from "@/shared/displayTextMappings"; +import { datasetLabels, entailmentLabels, statusLabels } from "@/shared/displayTextMappings"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { ActivatedRoute, Router } from "@angular/router"; import { IconButtonComponent } from "@/shared/icon-button/icon-button.component"; import { AuthService } from "@/services/auth.service"; +import { StatusInfoModalComponent } from "./status-info-modal/status-info-modal.component"; interface SearchParams { dataset: Dataset | null; entailmentLabel: EntailmentLabel | null; - gold: boolean | null; + status: ProblemStatus | null; text: string | null; hidden: boolean | null; } @@ -52,11 +53,12 @@ export class SearchComponent { private router = inject(Router); private route = inject(ActivatedRoute); private authService = inject(AuthService); + private modalService = inject(NgbModal); public form = new FormGroup({ dataset: new FormControl(null), entailmentLabel: new FormControl(null), - gold: new FormControl(null), + status: new FormControl(null), text: new FormControl(""), hidden: new FormControl(null), }); @@ -70,6 +72,7 @@ export class SearchComponent { public faSearch = faSearch; public faTimes = faTimes; + public faCircleInfo = faCircleInfo; public datasetOptions: SelectOption[] = Object.values(Dataset).map( (dataset) => ({ @@ -85,10 +88,12 @@ export class SearchComponent { }) ); - public goldOptions: SelectOption[] = [ - { value: true, label: $localize`Gold Only` }, - { value: false, label: $localize`Non-Gold Only` }, - ]; + public statusOptions: SelectOption[] = Object.values(ProblemStatus).map( + (status) => ({ + value: status, + label: statusLabels[status], + }) + ); public hiddenOptions: SelectOption[] = [ { value: true, label: $localize`Hidden Only` }, @@ -112,11 +117,12 @@ export class SearchComponent { ).subscribe(queryParams => { const dataset = queryParams.get('dataset'); const entailmentLabel = queryParams.get('entailmentLabel'); + const status = queryParams.get('status'); this.form.patchValue({ dataset: this.isDataset(dataset) ? dataset : null, entailmentLabel: this.isEntailmentLabel(entailmentLabel) ? entailmentLabel : null, - gold: queryParams.get('gold') === null ? null : queryParams.get('gold') === 'true', + status: this.isProblemStatus(status) ? status : null, text: queryParams.get('text') as string | null, hidden: queryParams.get('hidden') === null ? null : queryParams.get('hidden') === 'true', }); @@ -132,10 +138,20 @@ export class SearchComponent { return value !== null && Object.values(EntailmentLabel).includes(value as EntailmentLabel); } + private isProblemStatus(value: string | null): value is ProblemStatus { + return value !== null && Object.values(ProblemStatus).includes(value as ProblemStatus); + } + public clearFilters(): void { this.form.reset(); } + public showStatusInfoModal(): void { + this.modalService.open(StatusInfoModalComponent, { + centered: true, + }); + } + // Updates the route, which triggers a new query. private updateUrl(searchParams: SearchParams): void { this.router.navigate([], { diff --git a/frontend/src/app/annotate/search/status-info-modal/status-info-modal.component.html b/frontend/src/app/annotate/search/status-info-modal/status-info-modal.component.html new file mode 100644 index 00000000..f899cd7c --- /dev/null +++ b/frontend/src/app/annotate/search/status-info-modal/status-info-modal.component.html @@ -0,0 +1,77 @@ + + + diff --git a/frontend/src/app/annotate/search/status-info-modal/status-info-modal.component.scss b/frontend/src/app/annotate/search/status-info-modal/status-info-modal.component.scss new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/frontend/src/app/annotate/search/status-info-modal/status-info-modal.component.scss @@ -0,0 +1 @@ + diff --git a/frontend/src/app/annotate/search/status-info-modal/status-info-modal.component.spec.ts b/frontend/src/app/annotate/search/status-info-modal/status-info-modal.component.spec.ts new file mode 100644 index 00000000..2efba84c --- /dev/null +++ b/frontend/src/app/annotate/search/status-info-modal/status-info-modal.component.spec.ts @@ -0,0 +1,25 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { StatusInfoModalComponent } from './status-info-modal.component'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { provideHttpClient } from '@angular/common/http'; + +describe('StatusInfoModalComponent', () => { + let component: StatusInfoModalComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [StatusInfoModalComponent], + providers: [NgbActiveModal, provideHttpClient()] + }) + .compileComponents(); + + fixture = TestBed.createComponent(StatusInfoModalComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/annotate/search/status-info-modal/status-info-modal.component.ts b/frontend/src/app/annotate/search/status-info-modal/status-info-modal.component.ts new file mode 100644 index 00000000..ae5f289b --- /dev/null +++ b/frontend/src/app/annotate/search/status-info-modal/status-info-modal.component.ts @@ -0,0 +1,22 @@ +import { StatusBadgeComponent } from '@/annotate/annotation-input/problem-details/status-badge/status-badge.component'; +import { AuthService } from '@/services/auth.service'; +import { ProblemStatus } from '@/types'; +import { CommonModule } from '@angular/common'; +import { Component, inject } from '@angular/core'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { map } from 'rxjs'; + +@Component({ + selector: 'la-status-info-modal', + imports: [StatusBadgeComponent, CommonModule], + templateUrl: './status-info-modal.component.html', + styleUrl: './status-info-modal.component.scss' +}) +export class StatusInfoModalComponent { + public activeModal = inject(NgbActiveModal); + public authService = inject(AuthService); + + public canChangeStatus$ = this.authService.currentUser$.pipe(map(user => user?.canChangeProblemStatus ?? false)); + + public ProblemStatus = ProblemStatus; +} diff --git a/frontend/src/app/menu/user-menu/user-menu.component.spec.ts b/frontend/src/app/menu/user-menu/user-menu.component.spec.ts index ca75a69f..c723df52 100644 --- a/frontend/src/app/menu/user-menu/user-menu.component.spec.ts +++ b/frontend/src/app/menu/user-menu/user-menu.component.spec.ts @@ -27,6 +27,7 @@ const fakeUserResponse: UserResponse = { canAddLabelAnnotations: false, canCopyProblem: false, canChangeProblemVisibility: false, + canChangeProblemStatus: false, }; const fakeAdminResponse: UserResponse = { @@ -43,6 +44,7 @@ const fakeAdminResponse: UserResponse = { canAddLabelAnnotations: true, canCopyProblem: true, canChangeProblemVisibility: true, + canChangeProblemStatus: true, }; describe("UserMenuComponent", () => { diff --git a/frontend/src/app/services/problem.service.spec.ts b/frontend/src/app/services/problem.service.spec.ts index 003d2a68..7e5342f3 100644 --- a/frontend/src/app/services/problem.service.spec.ts +++ b/frontend/src/app/services/problem.service.spec.ts @@ -1,7 +1,7 @@ import { TestBed } from "@angular/core/testing"; import { HttpTestingController, provideHttpClientTesting } from "@angular/common/http/testing"; import { ProblemService } from "./problem.service"; -import { Dataset, EntailmentLabel, Problem, ProblemResponse, SaveProblemResponse } from "@/types"; +import { Dataset, EntailmentLabel, Problem, ProblemResponse, ProblemStatus, SaveProblemResponse } from "@/types"; import { convertToParamMap } from "@angular/router"; import { provideHttpClient } from "@angular/common/http"; import { ParseInput } from "@/annotate/annotation-input/annotation-input.component"; @@ -61,6 +61,8 @@ describe("ProblemService", () => { kbAnnotations: [], labelAnnotations: [], hidden: false, + gold: false, + status: ProblemStatus.BRONZE, }, index: 1, total: 1, @@ -82,7 +84,7 @@ describe("ProblemService", () => { done(); }); - const req = httpMock.expectOne(`/api/problem/${mockProblemId}/?text=&dataset=&gold=&entailmentLabel=&hidden=`); + const req = httpMock.expectOne(`/api/problem/${mockProblemId}/?text=&dataset=&status=&entailmentLabel=&hidden=`); expect(req.request.method).toBe("GET"); req.flush(mockResponse); }); @@ -105,7 +107,7 @@ describe("ProblemService", () => { expect(req.request.method).toBe("GET"); expect(req.request.params.get("text")).toBe(""); expect(req.request.params.get("dataset")).toBe(""); - expect(req.request.params.get("gold")).toBe(""); + expect(req.request.params.get("status")).toBe(""); expect(req.request.params.get("entailmentLabel")).toBe(""); req.flush("Not Found", { status: 404, statusText: "Not Found" }); }); @@ -123,7 +125,7 @@ describe("ProblemService", () => { const req = httpMock.expectOne(r => r.url.startsWith("/api/problem/abc")); expect(req.request.params.get("text")).toBe("search"); expect(req.request.params.get("dataset")).toBe("FRACAS"); - expect(req.request.params.get("gold")).toBe(""); + expect(req.request.params.get("status")).toBe(""); expect(req.request.params.get("entailmentLabel")).toBe(""); req.flush({}); }); diff --git a/frontend/src/app/services/problem.service.ts b/frontend/src/app/services/problem.service.ts index 132b0266..aa02de39 100644 --- a/frontend/src/app/services/problem.service.ts +++ b/frontend/src/app/services/problem.service.ts @@ -1,6 +1,6 @@ import { ParseInput } from "@/annotate/annotation-input/annotation-input.component"; import extractBaseParam from "@/shared/extractBaseParam"; -import { ProblemResponse, SaveProblemResponse, Dataset, EntailmentLabel, Problem, Label, ToggleVisibilityInput } from "@/types"; +import { ProblemResponse, SaveProblemResponse, Dataset, EntailmentLabel, Problem, Label, ToggleVisibilityInput, ToggleGoldInput, ProblemStatus } from "@/types"; import { HttpClient, HttpParams } from "@angular/common/http"; import { Injectable, inject } from "@angular/core"; import { ParamMap } from "@angular/router"; @@ -31,7 +31,8 @@ export class ProblemService { // Submit a new problem to be saved to the database. public submit$ = new Subject(); public refetchProblem$ = new Subject(); - public toggleVisibility$ = new Subject(); + public toggleVisibility$ = new Subject(); + public toggleGold$ = new Subject(); private visibilityToggleSuccess$ = this.toggleVisibility$.pipe( exhaustMap(({ id, hidden }) => @@ -50,10 +51,28 @@ export class ProblemService { map(() => this.allParams$.value), ); + private goldToggleSuccess$ = this.toggleGold$.pipe( + exhaustMap(({ id, gold }) => + this.http.post(`/api/problem/${id}/set-status/`, { gold }).pipe( + catchError((error) => { + this.toastService.show({ + header: $localize`Error updating status`, + body: error.message || $localize`Could not update problem status.`, + type: 'danger', + }); + return of(null); + }) + ) + ), + filter(result => result !== null), + map(() => this.allParams$.value), + ); + public problemResponse$: Observable = merge( this.allParams$, this.refetchProblem$.pipe(map(() => this.allParams$.value)), this.visibilityToggleSuccess$, + this.goldToggleSuccess$, ).pipe( filter(allParams => allParams !== null), switchMap(({ params, queryParams }) => { @@ -150,6 +169,8 @@ export class ProblemService { premises: existingProblem?.premises ?? [], entailmentLabel: EntailmentLabel.UNKNOWN, hidden: false, + gold: false, + status: ProblemStatus.BRONZE, extraData: null, kbAnnotations: existingProblem?.kbAnnotations.map(annotation => ({ ...annotation, id: null, @@ -186,14 +207,14 @@ export class ProblemService { private extractSearchParams(routeParams: ParamMap): HttpParams { const text = routeParams.get("text"); const dataset = routeParams.get("dataset"); - const gold = routeParams.get("gold"); + const status = routeParams.get("status"); const entailmentLabel = routeParams.get("entailmentLabel"); const hidden = routeParams.get("hidden"); const paramRecord: Record = { text: text ?? '', dataset: dataset ?? '', - gold: gold ?? '', + status: status ?? '', entailmentLabel: entailmentLabel ?? '', hidden: hidden ?? '', }; diff --git a/frontend/src/app/shared/displayTextMappings.ts b/frontend/src/app/shared/displayTextMappings.ts index b94bcc53..e2911c6a 100644 --- a/frontend/src/app/shared/displayTextMappings.ts +++ b/frontend/src/app/shared/displayTextMappings.ts @@ -1,4 +1,4 @@ -import { Dataset, EntailmentLabel } from "@/types"; +import { Dataset, EntailmentLabel, ProblemStatus } from "@/types"; export const entailmentLabels: Record = { [EntailmentLabel.ENTAILMENT]: $localize`Entailment`, @@ -13,3 +13,9 @@ export const datasetLabels: Record = { [Dataset.SNLI]: "SNLI", [Dataset.USER]: "User", }; + +export const statusLabels: Record = { + [ProblemStatus.GOLD]: $localize`Gold`, + [ProblemStatus.SILVER]: $localize`Silver`, + [ProblemStatus.BRONZE]: $localize`Bronze`, +}; diff --git a/frontend/src/app/types.ts b/frontend/src/app/types.ts index 522dce80..6c34f14a 100644 --- a/frontend/src/app/types.ts +++ b/frontend/src/app/types.ts @@ -65,6 +65,8 @@ interface ProblemBase { hypothesis: string | null; entailmentLabel: EntailmentLabel; hidden: boolean; + gold: boolean; + status: ProblemStatus; kbAnnotations: KnowledgeBaseAnnotation[]; labelAnnotations: LabelAnnotation[]; } @@ -127,6 +129,12 @@ export enum EntailmentLabel { UNKNOWN = "unknown", } +export enum ProblemStatus { + GOLD = "gold", + SILVER = "silver", + BRONZE = "bronze", +} + export interface Dimensions { width: number; @@ -201,3 +209,7 @@ export interface ProofTree { export interface ToggleVisibilityInput { hidden: boolean; } + +export interface ToggleGoldInput { + gold: boolean; +} diff --git a/frontend/src/app/user/models/user.ts b/frontend/src/app/user/models/user.ts index 66d389a5..7ce880d1 100644 --- a/frontend/src/app/user/models/user.ts +++ b/frontend/src/app/user/models/user.ts @@ -12,6 +12,7 @@ export interface UserResponse { canAddLabelAnnotations: boolean; canCopyProblem: boolean; canChangeProblemVisibility: boolean; + canChangeProblemStatus: boolean; } // Corresponds to frontend user type. @@ -37,6 +38,7 @@ export class User { public canAddLabelAnnotations: boolean, public canCopyProblem: boolean, public canChangeProblemVisibility: boolean, + public canChangeProblemStatus: boolean, ) { } } diff --git a/frontend/src/app/user/user-settings/user-settings.component.spec.ts b/frontend/src/app/user/user-settings/user-settings.component.spec.ts index 0fdef924..6a70ad1b 100644 --- a/frontend/src/app/user/user-settings/user-settings.component.spec.ts +++ b/frontend/src/app/user/user-settings/user-settings.component.spec.ts @@ -30,6 +30,7 @@ const fakeUser: User = { canAddLabelAnnotations: false, canCopyProblem: false, canChangeProblemVisibility: false, + canChangeProblemStatus: false, }; @Injectable({ providedIn: "root" }) diff --git a/frontend/src/app/user/utils.spec.ts b/frontend/src/app/user/utils.spec.ts index 1885f239..f6ce6f91 100644 --- a/frontend/src/app/user/utils.spec.ts +++ b/frontend/src/app/user/utils.spec.ts @@ -46,6 +46,7 @@ describe("User utils", () => { canAddLabelAnnotations: false, canCopyProblem: false, canChangeProblemVisibility: false, + canChangeProblemStatus: false, }; const user = parseUserData(result); expect(user).toBeInstanceOf(User); diff --git a/frontend/src/app/user/utils.ts b/frontend/src/app/user/utils.ts index f6d7079f..305f77e5 100644 --- a/frontend/src/app/user/utils.ts +++ b/frontend/src/app/user/utils.ts @@ -36,6 +36,7 @@ export const parseUserData = (result: UserResponse | null): User | null => { result.canAddLabelAnnotations, result.canCopyProblem, result.canChangeProblemVisibility, + result.canChangeProblemStatus, ); };