From 7adbff75b206ef567653c72ff5c1457f41187060 Mon Sep 17 00:00:00 2001 From: Xander Vertegaal Date: Wed, 20 May 2026 16:57:45 +0200 Subject: [PATCH 01/10] Add "gold" and "status" fields to Problem model --- .../problem/migrations/0010_problem_gold.py | 16 +++++++++++++ backend/problem/models.py | 23 +++++++++++++++++++ backend/problem/serializers.py | 3 +++ 3 files changed, 42 insertions(+) create mode 100644 backend/problem/migrations/0010_problem_gold.py diff --git a/backend/problem/migrations/0010_problem_gold.py b/backend/problem/migrations/0010_problem_gold.py new file mode 100644 index 0000000..d2e58dc --- /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 ba8bc24..5a3c1dc 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/serializers.py b/backend/problem/serializers.py index 8e58a83..4cd3f29 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): From ceb32e9e7955b919c68952fbf29a3f53e2cf276e Mon Sep 17 00:00:00 2001 From: Xander Vertegaal Date: Wed, 20 May 2026 17:00:59 +0200 Subject: [PATCH 02/10] Implement status change permission check --- backend/problem/views/problem.py | 7 +++++++ backend/user/models.py | 7 +++++++ backend/user/serializers.py | 4 ++++ 3 files changed, 18 insertions(+) diff --git a/backend/problem/views/problem.py b/backend/problem/views/problem.py index 99d7088..3aa09e8 100644 --- a/backend/problem/views/problem.py +++ b/backend/problem/views/problem.py @@ -45,6 +45,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 +61,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: diff --git a/backend/user/models.py b/backend/user/models.py index 6b2df2b..1722b7c 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 3f0fc06..e2bc906 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"] From dad8129f31159f00dfd08a4460591f618ee8c390 Mon Sep 17 00:00:00 2001 From: Xander Vertegaal Date: Wed, 20 May 2026 17:01:58 +0200 Subject: [PATCH 03/10] Apply status filter in backend --- backend/problem/problem_details.py | 40 +++++++++++++++++++++++++++--- backend/problem/views/problem.py | 22 ++++++++++++++++ 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/backend/problem/problem_details.py b/backend/problem/problem_details.py index 7cf74c9..09077f4 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/views/problem.py b/backend/problem/views/problem.py index 3aa09e8..225b8a8 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 @@ -76,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: """ @@ -122,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: From 00bf33f47447fd28e263f9eb6b032aab155d2792 Mon Sep 17 00:00:00 2001 From: Xander Vertegaal Date: Wed, 20 May 2026 22:46:53 +0200 Subject: [PATCH 04/10] Implement status filter in frontend --- .../gold-toggle.component.html | 16 ++++++++++ .../gold-toggle.component.scss | 0 .../problem-details/gold-toggle.component.ts | 26 +++++++++++++++++ .../problem-details.component.html | 25 +++++++++++++--- .../problem-details.component.ts | 13 ++++++--- .../app/annotate/search/search.component.html | 16 +++++----- .../annotate/search/search.component.spec.ts | 2 +- .../app/annotate/search/search.component.ts | 24 +++++++++------ .../src/app/services/problem.service.spec.ts | 10 ++++--- frontend/src/app/services/problem.service.ts | 29 ++++++++++++++++--- frontend/src/app/types.ts | 12 ++++++++ 11 files changed, 139 insertions(+), 34 deletions(-) create mode 100644 frontend/src/app/annotate/annotation-input/problem-details/gold-toggle.component.html create mode 100644 frontend/src/app/annotate/annotation-input/problem-details/gold-toggle.component.scss create mode 100644 frontend/src/app/annotate/annotation-input/problem-details/gold-toggle.component.ts diff --git a/frontend/src/app/annotate/annotation-input/problem-details/gold-toggle.component.html b/frontend/src/app/annotate/annotation-input/problem-details/gold-toggle.component.html new file mode 100644 index 0000000..d1110d1 --- /dev/null +++ b/frontend/src/app/annotate/annotation-input/problem-details/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.component.scss b/frontend/src/app/annotate/annotation-input/problem-details/gold-toggle.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/app/annotate/annotation-input/problem-details/gold-toggle.component.ts b/frontend/src/app/annotate/annotation-input/problem-details/gold-toggle.component.ts new file mode 100644 index 0000000..9128d16 --- /dev/null +++ b/frontend/src/app/annotate/annotation-input/problem-details/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 7e8fa4e..d0bfc86 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,28 @@ @let appMode = appMode$ | async; @let currentUser = currentUser$ | async; - @if (problemDetails(); as details) {
- @if (currentUser?.canChangeProblemVisibility) { - - } +
+ + @if (currentUser?.canChangeProblemStatus) { + + } + @if (currentUser?.canChangeProblemVisibility) { + + } +
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 afa0786..fcb787a 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,16 +1,17 @@ -import { Dataset, EntailmentLabel, LabelAnnotation, Problem } from "../../../types"; +import { Dataset, EntailmentLabel, LabelAnnotation, Problem, ProblemStatus } 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, faMedal, faQuestionCircle } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeModule } from "@fortawesome/angular-fontawesome"; import { NgbTooltipModule } from "@ng-bootstrap/ng-bootstrap"; -import { datasetLabels } from "@/shared/displayTextMappings"; +import { datasetLabels, statusLabels } from "@/shared/displayTextMappings"; import { ProblemLabelsComponent } from "./problem-labels/problem-labels.component"; import { CommonModule } from "@angular/common"; 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.component"; export interface ProblemDetails { problemId: string; @@ -33,7 +34,8 @@ export interface ProblemDetails { NgbTooltipModule, CommonModule, RouterModule, - VisibilityToggleComponent + VisibilityToggleComponent, + GoldToggleComponent, ], templateUrl: "./problem-details.component.html", styleUrl: "./problem-details.component.scss", @@ -56,7 +58,10 @@ export class ProblemDetailsComponent { public faQuestionCircle = faQuestionCircle; public faArrowUpRight = faArrowUpRightFromSquare; + public faMedal = faMedal; public datasetLabels = datasetLabels; + public statusLabels = statusLabels; + public ProblemStatus = ProblemStatus; public sectionString = computed(() => { const problemDetails = this.problemDetails(); diff --git a/frontend/src/app/annotate/search/search.component.html b/frontend/src/app/annotate/search/search.component.html index 55e50dc..0b72c39 100644 --- a/frontend/src/app/annotate/search/search.component.html +++ b/frontend/src/app/annotate/search/search.component.html @@ -40,18 +40,18 @@ [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 43b05de..ba8d041 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 15d69af..4a5bafd 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 { @@ -15,7 +15,7 @@ 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"; @@ -24,7 +24,7 @@ import { AuthService } from "@/services/auth.service"; interface SearchParams { dataset: Dataset | null; entailmentLabel: EntailmentLabel | null; - gold: boolean | null; + status: ProblemStatus | null; text: string | null; hidden: boolean | null; } @@ -56,7 +56,7 @@ export class SearchComponent { 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), }); @@ -85,10 +85,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` }, @@ -116,7 +118,7 @@ export class SearchComponent { 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(queryParams.get('status')) ? queryParams.get('status') as ProblemStatus : null, text: queryParams.get('text') as string | null, hidden: queryParams.get('hidden') === null ? null : queryParams.get('hidden') === 'true', }); @@ -132,6 +134,10 @@ 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(); } diff --git a/frontend/src/app/services/problem.service.spec.ts b/frontend/src/app/services/problem.service.spec.ts index 003d2a6..7e5342f 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 132b026..aa02de3 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/types.ts b/frontend/src/app/types.ts index 522dce8..6c34f14 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; +} From 9cb975802fd5803aa15f75d3550cb15e60f133d5 Mon Sep 17 00:00:00 2001 From: Xander Vertegaal Date: Wed, 20 May 2026 22:48:06 +0200 Subject: [PATCH 05/10] Add canChangeProblemStatus to user --- frontend/src/app/shared/displayTextMappings.ts | 8 +++++++- frontend/src/app/user/models/user.ts | 2 ++ frontend/src/app/user/utils.ts | 1 + 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/shared/displayTextMappings.ts b/frontend/src/app/shared/displayTextMappings.ts index b94bcc5..e2911c6 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/user/models/user.ts b/frontend/src/app/user/models/user.ts index 66d389a..7ce880d 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/utils.ts b/frontend/src/app/user/utils.ts index f6d7079..305f77e 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, ); }; From dc50912bb38eebb37d657c10b7e0a928754eb209 Mon Sep 17 00:00:00 2001 From: Xander Vertegaal Date: Wed, 20 May 2026 22:59:29 +0200 Subject: [PATCH 06/10] Add backend tests for Problem.status --- backend/annotation/serializers_test.py | 2 - backend/problem/conftest.py | 35 +++++++++++ backend/problem/models_test.py | 87 ++++++++++++++++++++++++++ backend/problem/serializers_test.py | 33 ---------- backend/user/tests/test_user_views.py | 1 + 5 files changed, 123 insertions(+), 35 deletions(-) create mode 100644 backend/problem/conftest.py create mode 100644 backend/problem/models_test.py diff --git a/backend/annotation/serializers_test.py b/backend/annotation/serializers_test.py index de578b5..30cdd8b 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 0000000..11c844d --- /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/models_test.py b/backend/problem/models_test.py new file mode 100644 index 0000000..11f9724 --- /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/serializers_test.py b/backend/problem/serializers_test.py index d891c97..aa9fd07 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/user/tests/test_user_views.py b/backend/user/tests/test_user_views.py index 4d3fdbc..3f9c5b8 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, } From 5252e63b6ce1f7d5107f6ae4d1d8e35971313ed8 Mon Sep 17 00:00:00 2001 From: Xander Vertegaal Date: Fri, 22 May 2026 16:04:38 +0200 Subject: [PATCH 07/10] Status change info modal and styling --- .../gold-toggle.component.html | 2 +- .../gold-toggle.component.scss | 0 .../gold-toggle.component.ts | 0 .../problem-details.component.html | 56 +++++++++------- .../problem-details.component.scss | 20 ++++++ .../problem-details.component.ts | 25 ++++--- .../status-badge/status-badge.component.html | 11 ++++ .../status-badge/status-badge.component.scss | 0 .../status-badge.component.spec.ts | 23 +++++++ .../status-badge/status-badge.component.ts | 19 ++++++ .../visibility-toggle.component.html | 48 ++++++-------- .../app/annotate/search/search.component.html | 18 ++++- .../app/annotate/search/search.component.ts | 13 +++- .../status-info-modal.component.html | 65 +++++++++++++++++++ .../status-info-modal.component.scss | 1 + .../status-info-modal.component.spec.ts | 23 +++++++ .../status-info-modal.component.ts | 16 +++++ 17 files changed, 272 insertions(+), 68 deletions(-) rename frontend/src/app/annotate/annotation-input/problem-details/{ => gold-toggle}/gold-toggle.component.html (84%) rename frontend/src/app/annotate/annotation-input/problem-details/{ => gold-toggle}/gold-toggle.component.scss (100%) rename frontend/src/app/annotate/annotation-input/problem-details/{ => gold-toggle}/gold-toggle.component.ts (100%) create mode 100644 frontend/src/app/annotate/annotation-input/problem-details/status-badge/status-badge.component.html create mode 100644 frontend/src/app/annotate/annotation-input/problem-details/status-badge/status-badge.component.scss create mode 100644 frontend/src/app/annotate/annotation-input/problem-details/status-badge/status-badge.component.spec.ts create mode 100644 frontend/src/app/annotate/annotation-input/problem-details/status-badge/status-badge.component.ts create mode 100644 frontend/src/app/annotate/search/status-info-modal/status-info-modal.component.html create mode 100644 frontend/src/app/annotate/search/status-info-modal/status-info-modal.component.scss create mode 100644 frontend/src/app/annotate/search/status-info-modal/status-info-modal.component.spec.ts create mode 100644 frontend/src/app/annotate/search/status-info-modal/status-info-modal.component.ts diff --git a/frontend/src/app/annotate/annotation-input/problem-details/gold-toggle.component.html b/frontend/src/app/annotate/annotation-input/problem-details/gold-toggle/gold-toggle.component.html similarity index 84% rename from frontend/src/app/annotate/annotation-input/problem-details/gold-toggle.component.html rename to frontend/src/app/annotate/annotation-input/problem-details/gold-toggle/gold-toggle.component.html index d1110d1..a710d1a 100644 --- a/frontend/src/app/annotate/annotation-input/problem-details/gold-toggle.component.html +++ b/frontend/src/app/annotate/annotation-input/problem-details/gold-toggle/gold-toggle.component.html @@ -1,6 +1,6 @@ @let gold = problem().gold;
@@ -89,5 +76,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 dca1f97..64e6b64 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.ts b/frontend/src/app/annotate/annotation-input/problem-details/problem-details.component.ts index fcb787a..d8df894 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,17 +1,19 @@ -import { Dataset, EntailmentLabel, LabelAnnotation, Problem, ProblemStatus } from "../../../types"; +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, faMedal, 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 { datasetLabels, statusLabels } from "@/shared/displayTextMappings"; +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"; 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.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; @@ -36,6 +38,7 @@ export interface ProblemDetails { RouterModule, VisibilityToggleComponent, GoldToggleComponent, + StatusBadgeComponent, ], templateUrl: "./problem-details.component.html", styleUrl: "./problem-details.component.scss", @@ -44,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$; @@ -57,11 +61,10 @@ export class ProblemDetailsComponent { }); public faQuestionCircle = faQuestionCircle; + public faEyeSlash = faEyeSlash; public faArrowUpRight = faArrowUpRightFromSquare; - public faMedal = faMedal; public datasetLabels = datasetLabels; - public statusLabels = statusLabels; - public ProblemStatus = ProblemStatus; + public faCircleInfo = faCircleInfo; public sectionString = computed(() => { const problemDetails = this.problemDetails(); @@ -80,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 0000000..bd6566e --- /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 0000000..e69de29 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 0000000..34f466f --- /dev/null +++ b/frontend/src/app/annotate/annotation-input/problem-details/status-badge/status-badge.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { StatusBadgeComponent } from './status-badge.component'; + +describe('StatusBadgeComponent', () => { + let component: StatusBadgeComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [StatusBadgeComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(StatusBadgeComponent); + component = fixture.componentInstance; + 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 0000000..323cd1b --- /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 32789d5..0b1187c 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/search/search.component.html b/frontend/src/app/annotate/search/search.component.html index 0b72c39..d769ad7 100644 --- a/frontend/src/app/annotate/search/search.component.html +++ b/frontend/src/app/annotate/search/search.component.html @@ -1,5 +1,5 @@ -@let loading = loading$ | async; -@let canChangeVisibility = canChangeVisibility$ | async; +@let loading = loading$ | async; @let canChangeVisibility = canChangeVisibility$ +| async;
@@ -40,7 +40,19 @@ [options]="entailmentLabelOptions" /> - +
+ + +
({ dataset: 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) => ({ @@ -142,6 +145,12 @@ export class SearchComponent { 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 0000000..91679ef --- /dev/null +++ b/frontend/src/app/annotate/search/status-info-modal/status-info-modal.component.html @@ -0,0 +1,65 @@ + + + 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 0000000..8b13789 --- /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 0000000..eca40d4 --- /dev/null +++ b/frontend/src/app/annotate/search/status-info-modal/status-info-modal.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { StatusInfoModalComponent } from './status-info-modal.component'; + +describe('StatusInfoModalComponent', () => { + let component: StatusInfoModalComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [StatusInfoModalComponent] + }) + .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 0000000..e8f8fe8 --- /dev/null +++ b/frontend/src/app/annotate/search/status-info-modal/status-info-modal.component.ts @@ -0,0 +1,16 @@ +import { StatusBadgeComponent } from '@/annotate/annotation-input/problem-details/status-badge/status-badge.component'; +import { ProblemStatus } from '@/types'; +import { Component, inject } from '@angular/core'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; + +@Component({ + selector: 'la-status-info-modal', + imports: [StatusBadgeComponent], + templateUrl: './status-info-modal.component.html', + styleUrl: './status-info-modal.component.scss' +}) +export class StatusInfoModalComponent { + public activeModal = inject(NgbActiveModal); + + public ProblemStatus = ProblemStatus; +} From f281433f82de34cf9881cd074863363c59fb1ce4 Mon Sep 17 00:00:00 2001 From: Xander Vertegaal Date: Fri, 22 May 2026 16:28:56 +0200 Subject: [PATCH 08/10] Fix and update frontend tests --- .../annotation-input.component.spec.ts | 16 +++++-- .../problem-details.component.html | 27 +++++++----- .../problem-details.component.spec.ts | 42 +++++++++++++++++-- .../status-badge.component.spec.ts | 3 ++ .../visibility-toggle.component.spec.ts | 12 ------ .../status-info-modal.component.spec.ts | 5 ++- .../user-menu/user-menu.component.spec.ts | 2 + .../user-settings.component.spec.ts | 1 + frontend/src/app/user/utils.spec.ts | 1 + 9 files changed, 78 insertions(+), 31 deletions(-) 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 ee61737..9547070 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/problem-details.component.html b/frontend/src/app/annotate/annotation-input/problem-details/problem-details.component.html index d6c546c..b576f34 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,14 +1,19 @@ -@let appMode = appMode$ | async; @let currentUser = currentUser$ | async; @let -hidden = problem().hidden; @let status = problem().status; @if -(problemDetails(); as details) { @if (hidden) { - -} +@let appMode = appMode$ | async; +@let currentUser = currentUser$ | async; +@let hidden = problem().hidden; +@let status = problem().status; + +@if (problemDetails(); as details) { + @if (hidden) { + + } +
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 26cf432..463673b 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/status-badge/status-badge.component.spec.ts b/frontend/src/app/annotate/annotation-input/problem-details/status-badge/status-badge.component.spec.ts index 34f466f..5477f9e 100644 --- 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 @@ -1,6 +1,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { StatusBadgeComponent } from './status-badge.component'; +import { ProblemStatus } from '@/types'; describe('StatusBadgeComponent', () => { let component: StatusBadgeComponent; @@ -14,6 +15,8 @@ describe('StatusBadgeComponent', () => { fixture = TestBed.createComponent(StatusBadgeComponent); component = fixture.componentInstance; + const componentRef = fixture.componentRef; + componentRef.setInput("status", ProblemStatus.BRONZE); fixture.detectChanges(); }); 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 b2b5e8e..f4b8300 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/status-info-modal/status-info-modal.component.spec.ts b/frontend/src/app/annotate/search/status-info-modal/status-info-modal.component.spec.ts index eca40d4..20262c8 100644 --- 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 @@ -1,6 +1,6 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; - import { StatusInfoModalComponent } from './status-info-modal.component'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; describe('StatusInfoModalComponent', () => { let component: StatusInfoModalComponent; @@ -8,7 +8,8 @@ describe('StatusInfoModalComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [StatusInfoModalComponent] + imports: [StatusInfoModalComponent], + providers: [NgbActiveModal] }) .compileComponents(); 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 ca75a69..c723df5 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/user/user-settings/user-settings.component.spec.ts b/frontend/src/app/user/user-settings/user-settings.component.spec.ts index 0fdef92..6a70ad1 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 1885f23..f6ce6f9 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); From 1c581940b11e8643374a89dc3607a7503fdce0af Mon Sep 17 00:00:00 2001 From: Xander Vertegaal Date: Mon, 25 May 2026 15:09:13 +0200 Subject: [PATCH 09/10] Rephrase descriptions --- .../status-info-modal.component.html | 30 +++++++++++++------ .../status-info-modal.component.ts | 8 ++++- 2 files changed, 28 insertions(+), 10 deletions(-) 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 index 91679ef..f899cd7 100644 --- 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 @@ -10,8 +10,9 @@