From b57b170bda148540f6fe7513c0786cf0c3229087 Mon Sep 17 00:00:00 2001 From: Xander Vertegaal Date: Wed, 28 Aug 2024 16:06:02 +0200 Subject: [PATCH 01/51] Remove wrapping div from table --- frontend/src/app/aethel/aethel.component.html | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/frontend/src/app/aethel/aethel.component.html b/frontend/src/app/aethel/aethel.component.html index 2773116..97a06f2 100644 --- a/frontend/src/app/aethel/aethel.component.html +++ b/frontend/src/app/aethel/aethel.component.html @@ -15,7 +15,7 @@

Æthel

class="input" [class.is-danger]="form.touched && form.invalid" type="text" - formControlName="aethelInput" + [formControl]="form.controls.word" placeholder="Enter a word or lemma..." placeholder-i18n /> @@ -42,7 +42,6 @@

Æthel

} @if (submitted | async) { -
Æthel -
} From b954300241cf4e71367c7898384ee3588c430dfc Mon Sep 17 00:00:00 2001 From: Xander Vertegaal Date: Wed, 28 Aug 2024 16:09:46 +0200 Subject: [PATCH 02/51] Add skip and limit --- frontend/src/app/aethel/aethel.component.ts | 40 +++++++++++-------- .../app/shared/services/aethel-api.service.ts | 6 +++ frontend/src/app/shared/types.ts | 3 ++ 3 files changed, 33 insertions(+), 16 deletions(-) diff --git a/frontend/src/app/aethel/aethel.component.ts b/frontend/src/app/aethel/aethel.component.ts index ea1e7dd..784eacf 100644 --- a/frontend/src/app/aethel/aethel.component.ts +++ b/frontend/src/app/aethel/aethel.component.ts @@ -1,7 +1,7 @@ import { Component, DestroyRef, OnInit } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormControl, FormGroup, Validators } from "@angular/forms"; -import { AethelListResult } from "../shared/types"; +import { AethelInput, AethelListResult } from "../shared/types"; import { AethelApiService } from "../shared/services/aethel-api.service"; import { Subject, distinctUntilChanged, map } from "rxjs"; import { @@ -19,11 +19,19 @@ import { StatusService } from "../shared/services/status.service"; }) export class AethelComponent implements OnInit { public form = new FormGroup({ - aethelInput: new FormControl("", { + word: new FormControl("", { + nonNullable: true, validators: [Validators.minLength(3)], }), + limit: new FormControl(10, { + nonNullable: true, + }), + skip: new FormControl(0, { + nonNullable: true, + }), }); public rows: AethelListResult[] = []; + public totalRowCount = 0; public loading$ = this.apiService.loading$; public submitted = this.apiService.output$.pipe(map(() => true)); @@ -32,7 +40,7 @@ export class AethelComponent implements OnInit { chevronDown: faChevronDown, }; - status$ = new Subject(); + public status$ = new Subject(); constructor( private apiService: AethelApiService, @@ -59,6 +67,7 @@ export class AethelComponent implements OnInit { // TODO: handle error! } this.rows = this.addUniqueKeys(response.results); + this.totalRowCount = response.totalCount; }); // Whenever the query parameter changes, we run a new query. @@ -69,12 +78,12 @@ export class AethelComponent implements OnInit { takeUntilDestroyed(this.destroyRef), ) .subscribe((query) => { - const word = query["word"]; + const word = query["word"] ?? ""; const type = query["type"]; - if (word) { - this.form.controls.aethelInput.setValue(word); - } - this.apiService.input$.next({ word, type }); + const skip = query["skip"] ?? 0; + const limit = query["limit"] ?? 10; + this.form.patchValue({ word, skip, limit }); + this.apiService.input$.next({ word, type, skip, limit }); }); } @@ -88,21 +97,20 @@ export class AethelComponent implements OnInit { public submit(): void { this.form.markAllAsTouched(); - this.form.controls.aethelInput.updateValueAndValidity(); - const query = this.form.controls.aethelInput.value; - if (!query) { - return; - } - this.updateUrl(query); + this.form.controls.word.updateValueAndValidity(); + const queryInput: AethelInput = this.form.getRawValue(); + this.updateUrl(queryInput); } - private updateUrl(query: string): void { + private updateUrl(queryInput: AethelInput): void { // This does not actually refresh the page because it just adds parameters to the current route. // It just updates the URL in the browser, triggering a new query. const url = this.router .createUrlTree([], { relativeTo: this.route, - queryParams: { word: query }, + queryParams: { + ...queryInput, + }, }) .toString(); this.router.navigateByUrl(url); diff --git a/frontend/src/app/shared/services/aethel-api.service.ts b/frontend/src/app/shared/services/aethel-api.service.ts index 0e2a81d..41a7362 100644 --- a/frontend/src/app/shared/services/aethel-api.service.ts +++ b/frontend/src/app/shared/services/aethel-api.service.ts @@ -44,6 +44,12 @@ export class AethelApiService if (input.type) { params = params.set("type", input.type); } + if (input.limit) { + params = params.set("limit", input.limit.toString()); + } + if (input.skip) { + params = params.set("skip", input.skip.toString()); + } return this.http .get(`${environment.apiUrl}aethel/`, { diff --git a/frontend/src/app/shared/types.ts b/frontend/src/app/shared/types.ts index c547915..71d114f 100644 --- a/frontend/src/app/shared/types.ts +++ b/frontend/src/app/shared/types.ts @@ -41,6 +41,8 @@ export type AethelMode = "word" | "type" | "word-and-type"; export interface AethelInput { word?: string; type?: string; + limit?: number; + skip?: number; } export interface AethelListLexicalItem { @@ -61,6 +63,7 @@ export interface AethelListResult { export interface AethelList { results: AethelListResult[]; + totalCount: number; error: string | null; } From 46c799695718d19aadfae0401c06180d6154c648 Mon Sep 17 00:00:00 2001 From: Xander Vertegaal Date: Wed, 28 Aug 2024 16:10:15 +0200 Subject: [PATCH 03/51] Listen to page changes --- frontend/src/app/aethel/aethel.component.html | 3 +++ frontend/src/app/aethel/aethel.component.ts | 15 +++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/frontend/src/app/aethel/aethel.component.html b/frontend/src/app/aethel/aethel.component.html index 97a06f2..7cfeb0f 100644 --- a/frontend/src/app/aethel/aethel.component.html +++ b/frontend/src/app/aethel/aethel.component.html @@ -44,9 +44,12 @@

Æthel

@if (submitted | async) { Date: Wed, 28 Aug 2024 16:15:58 +0200 Subject: [PATCH 04/51] Handle limit and skip in backend --- backend/aethel_db/views/list.py | 43 +++++++++++++++++++++++++++++---- 1 file changed, 38 insertions(+), 5 deletions(-) diff --git a/backend/aethel_db/views/list.py b/backend/aethel_db/views/list.py index 8c4f538..fdcf84a 100644 --- a/backend/aethel_db/views/list.py +++ b/backend/aethel_db/views/list.py @@ -1,4 +1,5 @@ from dataclasses import asdict, dataclass, field +from enum import Enum from django.http import HttpRequest, JsonResponse from rest_framework import status @@ -22,6 +23,11 @@ class AethelListPhrase: items: list[AethelListLexicalItem] +class AethelListError(Enum): + INVALID_LIMIT_OR_SKIP = "INVALID_LIMIT_OR_SKIP" + WORD_TOO_SHORT = "WORD_TOO_SHORT" + + @dataclass class AethelListResult: phrase: AethelListPhrase @@ -48,7 +54,9 @@ class AethelListResponse: """ results: dict[tuple[str, str, str], AethelListResult] = field(default_factory=dict) - error: str | None = None + error: AethelListError | None = None + limit: int = 10 + skip: int = 0 def get_or_create_result( self, phrase: LexicalPhrase, type: Type @@ -82,11 +90,24 @@ def get_or_create_result( return self.results.setdefault(key, new_result) def json_response(self) -> JsonResponse: - results = [result.serialize() for result in self.results.values()] + if self.error: + return JsonResponse( + { + "results": [], + "totalCount": 0, + "error": self.error, + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + total_count = len(self.results) + paginated = list(self.results.values())[self.skip : self.skip + self.limit] + serialized = [result.serialize() for result in paginated] return JsonResponse( { - "results": results, + "results": serialized, + "totalCount": total_count, "error": self.error, }, status=status.HTTP_200_OK, @@ -97,12 +118,24 @@ class AethelListView(APIView): def get(self, request: HttpRequest) -> JsonResponse: word_input = self.request.query_params.get("word", None) type_input = self.request.query_params.get("type", None) + limit = self.request.query_params.get("limit", 10) + skip = self.request.query_params.get("skip", 0) + + try: + limit = int(limit) + skip = int(skip) + except ValueError: + return AethelListResponse( + error=AethelListError.INVALID_LIMIT_OR_SKIP + ).json_response() # We only search for strings of 3 or more characters. if word_input is not None and len(word_input) < 3: - return AethelListResponse().json_response() + return AethelListResponse( + error=AethelListError.WORD_TOO_SHORT + ).json_response() - response_object = AethelListResponse() + response_object = AethelListResponse(skip=skip, limit=limit) for sample in dataset.samples: for phrase in sample.lexical_phrases: From 76174cd99228f1f0baa65286eb23d9e8eee507fd Mon Sep 17 00:00:00 2001 From: Xander Vertegaal Date: Wed, 28 Aug 2024 17:00:17 +0200 Subject: [PATCH 05/51] Use first and limit in template --- frontend/src/app/aethel/aethel.component.html | 3 ++- frontend/src/app/aethel/aethel.component.ts | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/aethel/aethel.component.html b/frontend/src/app/aethel/aethel.component.html index 7cfeb0f..55460c7 100644 --- a/frontend/src/app/aethel/aethel.component.html +++ b/frontend/src/app/aethel/aethel.component.html @@ -48,7 +48,8 @@

Æthel

[lazy]="true" dataKey="key" [paginator]="true" - [rows]="10" + [first]="form.controls.skip.value" + [rows]="form.controls.limit.value" (onPage)="changePage($event)" [rowsPerPageOptions]="[10, 25, 50]" [showCurrentPageReport]="true" diff --git a/frontend/src/app/aethel/aethel.component.ts b/frontend/src/app/aethel/aethel.component.ts index a227a8b..6bf498e 100644 --- a/frontend/src/app/aethel/aethel.component.ts +++ b/frontend/src/app/aethel/aethel.component.ts @@ -95,8 +95,8 @@ export class AethelComponent implements OnInit { .subscribe((query) => { const word = query["word"] ?? ""; const type = query["type"]; - const skip = query["skip"] ?? 0; - const limit = query["limit"] ?? 10; + const skip = parseInt(query["skip"], 10) ?? 0; + const limit = parseInt(query["limit"], 10) ?? 10; this.form.patchValue({ word, skip, limit }); this.apiService.input$.next({ word, type, skip, limit }); }); From 47e4daebb599774856fe0547951fcbcba936edd7 Mon Sep 17 00:00:00 2001 From: Xander Vertegaal Date: Fri, 30 Aug 2024 08:32:11 +0200 Subject: [PATCH 06/51] Fix failing tests --- .../src/app/aethel/aethel.component.spec.ts | 27 +++++++++++-------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/frontend/src/app/aethel/aethel.component.spec.ts b/frontend/src/app/aethel/aethel.component.spec.ts index 6ebfb7c..89f401c 100644 --- a/frontend/src/app/aethel/aethel.component.spec.ts +++ b/frontend/src/app/aethel/aethel.component.spec.ts @@ -1,6 +1,9 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { AethelComponent } from "./aethel.component"; -import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing"; +import { + HttpClientTestingModule, + HttpTestingController, +} from "@angular/common/http/testing"; import { CommonModule } from "@angular/common"; import { ReactiveFormsModule } from "@angular/forms"; import { ActivatedRoute, Router, RouterModule } from "@angular/router"; @@ -23,7 +26,7 @@ describe("AethelComponent", () => { HttpClientTestingModule, ReactiveFormsModule, CommonModule, - RouterModule.forRoot(routes) + RouterModule.forRoot(routes), ], }).compileComponents(); httpController = TestBed.inject(HttpTestingController); @@ -42,28 +45,30 @@ describe("AethelComponent", () => { it("should not request data if there is no query parameter", () => { route.queryParams = of({}); component.ngOnInit(); - expect(component.form.controls.aethelInput.value).toBe(""); + expect(component.form.controls.word.value).toBe(""); httpController.expectNone("/api/aethel"); }); - it("should insert data into the form when there is a 'word' query parameter", () => { - route.queryParams = of({ word: "test" }); + it("should insert data into the form when there are query parameters", () => { + route.queryParams = of({ word: "test", skip: 420, limit: 666 }); component.ngOnInit(); - expect(component.form.controls.aethelInput.value).toBe("test"); + expect(component.form.controls.word.value).toBe("test"); + expect(component.form.controls.skip.value).toBe(420); + expect(component.form.controls.limit.value).toBe(666); }); it("should pass query param data to the API service", () => { - apiService.input$.subscribe(input => { + apiService.input$.subscribe((input) => { expect(input.word).toBe("test3"); - }); + }); route.queryParams = of({ word: "test3" }); component.ngOnInit(); }); it("should react to form submissions", () => { const navigatorSpy = spyOn(router, "navigateByUrl"); - component.form.controls.aethelInput.setValue("test-two"); - component.submit(); - expect(navigatorSpy).toHaveBeenCalledWith("/?word=test-two"); + component.form.controls.word.setValue("test-two"); + component.submitWord(); + expect(navigatorSpy).toHaveBeenCalledWith("/?word=test-two&limit=10&skip=0"); }); }); From ae6a238cd97c9076638808f743968c08c9e58dfc Mon Sep 17 00:00:00 2001 From: Xander Vertegaal Date: Fri, 30 Aug 2024 08:32:22 +0200 Subject: [PATCH 07/51] Serialize errors --- backend/aethel_db/views/list.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/aethel_db/views/list.py b/backend/aethel_db/views/list.py index fdcf84a..7611d74 100644 --- a/backend/aethel_db/views/list.py +++ b/backend/aethel_db/views/list.py @@ -95,7 +95,7 @@ def json_response(self) -> JsonResponse: { "results": [], "totalCount": 0, - "error": self.error, + "error": self.error.value, }, status=status.HTTP_400_BAD_REQUEST, ) From 010dbf696ebade4b0de888e8ea29b358dad7ebdc Mon Sep 17 00:00:00 2001 From: Xander Vertegaal Date: Fri, 30 Aug 2024 08:33:17 +0200 Subject: [PATCH 08/51] Return to first page on new search --- frontend/src/app/aethel/aethel.component.html | 6 +++--- frontend/src/app/aethel/aethel.component.ts | 19 ++++++++++++++----- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/frontend/src/app/aethel/aethel.component.html b/frontend/src/app/aethel/aethel.component.html index 55460c7..bae67ae 100644 --- a/frontend/src/app/aethel/aethel.component.html +++ b/frontend/src/app/aethel/aethel.component.html @@ -6,7 +6,7 @@

Æthel

@if (status$ | async) { -
+
@@ -44,10 +44,10 @@

Æthel

@if (submitted | async) { { const word = query["word"] ?? ""; const type = query["type"]; - const skip = parseInt(query["skip"], 10) ?? 0; - const limit = parseInt(query["limit"], 10) ?? 10; + const skip = query["skip"] ? parseInt(query["skip"], 10) : 0; + const limit = query["limit"] + ? parseInt(query["limit"], 10) + : 10; + this.form.patchValue({ word, skip, limit }); this.apiService.input$.next({ word, type, skip, limit }); }); @@ -110,7 +113,13 @@ export class AethelComponent implements OnInit { return row.phrase.items.map((item) => item.lemma).join(" "); } - public submit(): void { + public submitWord(): void { + // When the user submits a new word, go back to the first page. + this.form.controls.skip.setValue(0); + this.prepareQuery(); + } + + private prepareQuery(): void { this.form.markAllAsTouched(); this.form.controls.word.updateValueAndValidity(); const queryInput: AethelInput = this.form.getRawValue(); @@ -119,7 +128,7 @@ export class AethelComponent implements OnInit { private updateUrl(queryInput: AethelInput): void { // This does not actually refresh the page because it just adds parameters to the current route. - // It just updates the URL in the browser, triggering a new query. + // This triggers a new query. const url = this.router .createUrlTree([], { relativeTo: this.route, From f2a479511967d10b679650c9b1008338c53242db Mon Sep 17 00:00:00 2001 From: Xander Vertegaal Date: Fri, 30 Aug 2024 09:01:16 +0200 Subject: [PATCH 09/51] Fix type search --- backend/aethel_db/views/list.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/backend/aethel_db/views/list.py b/backend/aethel_db/views/list.py index 7611d74..96ce483 100644 --- a/backend/aethel_db/views/list.py +++ b/backend/aethel_db/views/list.py @@ -9,7 +9,7 @@ from aethel_db.models import dataset from aethel.frontend import LexicalPhrase -from aethel.mill.types import type_prefix, Type, type_repr +from aethel.mill.types import type_prefix, Type, type_repr, parse_prefix @dataclass @@ -26,6 +26,7 @@ class AethelListPhrase: class AethelListError(Enum): INVALID_LIMIT_OR_SKIP = "INVALID_LIMIT_OR_SKIP" WORD_TOO_SHORT = "WORD_TOO_SHORT" + CANNOT_PARSE_TYPE = "CANNOT_PARSE_TYPE" @dataclass @@ -129,23 +130,29 @@ def get(self, request: HttpRequest) -> JsonResponse: error=AethelListError.INVALID_LIMIT_OR_SKIP ).json_response() + response_object = AethelListResponse(skip=skip, limit=limit) + # We only search for strings of 3 or more characters. if word_input is not None and len(word_input) < 3: return AethelListResponse( error=AethelListError.WORD_TOO_SHORT ).json_response() - response_object = AethelListResponse(skip=skip, limit=limit) + try: + parsed_type = parse_prefix(type_input) if type_input else None + except IndexError or AttributeError: + response_object.error = AethelListError.CANNOT_PARSE_TYPE + return response_object.json_response() + for sample in dataset.samples: for phrase in sample.lexical_phrases: word_match = word_input and match_word_with_phrase(phrase, word_input) - type_match = type_input and match_type_with_phrase(phrase, type_input) + type_match = parsed_type and match_type_with_phrase(phrase, parsed_type) if not (word_match or type_match): continue result = response_object.get_or_create_result( - # type_prefix returns a string representation of the type, with spaces between the elements. phrase=phrase, type=phrase.type, ) From 8914c26569c385d27caedef1eb4d9abd70e775e7 Mon Sep 17 00:00:00 2001 From: Xander Vertegaal Date: Fri, 30 Aug 2024 11:23:27 +0200 Subject: [PATCH 10/51] Return displayType in detail view --- backend/aethel_db/views/detail.py | 25 +++++++++++++++++++++---- backend/spindle/utils.py | 14 +++++++++++--- 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/backend/aethel_db/views/detail.py b/backend/aethel_db/views/detail.py index ea20558..b857b64 100644 --- a/backend/aethel_db/views/detail.py +++ b/backend/aethel_db/views/detail.py @@ -1,5 +1,5 @@ from enum import Enum -from dataclasses import dataclass, asdict +from dataclasses import dataclass from django.http import HttpRequest, JsonResponse from rest_framework import status @@ -16,7 +16,23 @@ class AethelDetailResult: name: str term: str subset: str - phrases: list[dict] + phrases: list[dict[str, str]] + + def serialize(self): + return { + "sentence": self.sentence, + "name": self.name, + "term": self.term, + "subset": self.subset, + "phrases": [ + { + "type": phrase["type"], + "displayType": phrase["display_type"], + "items": phrase["items"], + } + for phrase in self.phrases + ], + } class AethelDetailError(Enum): @@ -47,14 +63,15 @@ def parse_sample(self, sample: Sample) -> None: ) def json_response(self) -> JsonResponse: - result = asdict(self.result) if self.result else None status_code = ( AETHEL_DETAIL_STATUS_CODES[self.error] if self.error else status.HTTP_200_OK ) + result_data = self.result.serialize() if self.result else None + return JsonResponse( { - "result": result, + "result": result_data if self.result else None, "error": self.error, }, status=status_code, diff --git a/backend/spindle/utils.py b/backend/spindle/utils.py index cfd5abe..f5793a9 100644 --- a/backend/spindle/utils.py +++ b/backend/spindle/utils.py @@ -1,12 +1,20 @@ from aethel.frontend import LexicalPhrase +from aethel.mill.types import type_prefix, type_repr def serialize_phrases_with_infix_notation( lexical_phrases: list[LexicalPhrase], ) -> list[dict[str, str]]: """ - Serializes a list of LexicalPhrases in a human-readable infix notation that is already available in Æthel in Type.__repr__. + Serializes a list of LexicalPhrases in a human-readable infix notation that is already available in Æthel in Type.__repr__ or type_repr(). This is used to display the types in the frontend. - The standard JSON serialization of phrases uses a prefix notation for types, which is good for data-exchange purposes (easier parsing) but less ideal for human consumption. + The standard JSON serialization of phrases uses a prefix notation for types, which is good for data-exchange purposes (easier parsing) but less ideal for human consumption. This notation will be used to query. """ - return [dict(phrase.json(), type=repr(phrase.type)) for phrase in lexical_phrases] + return [ + dict( + phrase.json(), + display_type=type_repr(phrase.type), + type=type_prefix(phrase.type), + ) + for phrase in lexical_phrases + ] From 9d463770a9333236bab673b8d9b3b115748a4cf5 Mon Sep 17 00:00:00 2001 From: Xander Vertegaal Date: Fri, 30 Aug 2024 11:23:46 +0200 Subject: [PATCH 11/51] Parse type in list view --- backend/aethel_db/views/list.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/backend/aethel_db/views/list.py b/backend/aethel_db/views/list.py index 8c4f538..abb881f 100644 --- a/backend/aethel_db/views/list.py +++ b/backend/aethel_db/views/list.py @@ -8,7 +8,7 @@ from aethel_db.models import dataset from aethel.frontend import LexicalPhrase -from aethel.mill.types import type_prefix, Type, type_repr +from aethel.mill.types import type_prefix, Type, type_repr, parse_prefix @dataclass @@ -102,17 +102,22 @@ def get(self, request: HttpRequest) -> JsonResponse: if word_input is not None and len(word_input) < 3: return AethelListResponse().json_response() + try: + parsed_type = parse_prefix(type_input) if type_input else None + except (ValueError, IndexError): + return AethelListResponse(error="Invalid type input.").json_response() + response_object = AethelListResponse() for sample in dataset.samples: for phrase in sample.lexical_phrases: word_match = word_input and match_word_with_phrase(phrase, word_input) - type_match = type_input and match_type_with_phrase(phrase, type_input) + type_match = parsed_type and match_type_with_phrase(phrase, parsed_type) + if not (word_match or type_match): continue result = response_object.get_or_create_result( - # type_prefix returns a string representation of the type, with spaces between the elements. phrase=phrase, type=phrase.type, ) From 3286eea532c632a74a5c204eacc7443c5327fe7c Mon Sep 17 00:00:00 2001 From: Xander Vertegaal Date: Fri, 30 Aug 2024 11:24:33 +0200 Subject: [PATCH 12/51] Use displayType in frontend --- frontend/src/app/sample/sample.component.html | 2 +- .../src/app/sample/sample.component.spec.ts | 11 +++++----- frontend/src/app/sample/sample.component.ts | 11 ++++++---- frontend/src/app/shared/types.ts | 11 +++++----- frontend/src/app/spindle/spindle.component.ts | 20 ++++++++++--------- 5 files changed, 31 insertions(+), 24 deletions(-) diff --git a/frontend/src/app/sample/sample.component.html b/frontend/src/app/sample/sample.component.html index 10af818..1795692 100644 --- a/frontend/src/app/sample/sample.component.html +++ b/frontend/src/app/sample/sample.component.html @@ -36,7 +36,7 @@ {{ item.word }} - + @if (showButtons(phrase.items)) { diff --git a/frontend/src/app/sample/sample.component.spec.ts b/frontend/src/app/sample/sample.component.spec.ts index e21d797..582593c 100644 --- a/frontend/src/app/sample/sample.component.spec.ts +++ b/frontend/src/app/sample/sample.component.spec.ts @@ -10,14 +10,15 @@ import { import { AethelDetail, AethelDetailError, - LexicalPhrase, + AethelDetailPhrase, } from "../shared/types"; import { By } from "@angular/platform-browser"; import { ProofPipe } from "../shared/pipes/proof.pipe"; import { FontAwesomeModule } from "@fortawesome/angular-fontawesome"; -const fakePhrase: LexicalPhrase = { +const fakePhrase: AethelDetailPhrase = { type: "cheese->tosti", + displayType: "cheese -> tosti", items: [ { word: "cheeses", @@ -71,7 +72,7 @@ describe("SampleComponent", () => { it("should construct a valid route for word search", () => { const spy = spyOn(router, "navigate"); - component.searchAethel(fakePhrase, 'word'); + component.searchAethel(fakePhrase, "word"); expect(spy).toHaveBeenCalledOnceWith(["/aethel"], { queryParams: { word: "cheeses" }, }); @@ -79,7 +80,7 @@ describe("SampleComponent", () => { it("should construct a valid route for type search", () => { const spy = spyOn(router, "navigate"); - component.searchAethel(fakePhrase, 'type'); + component.searchAethel(fakePhrase, "type"); expect(spy).toHaveBeenCalledOnceWith(["/aethel"], { queryParams: { type: "cheese->tosti" }, }); @@ -87,7 +88,7 @@ describe("SampleComponent", () => { it("should construct a valid route for word and type search", () => { const spy = spyOn(router, "navigate"); - component.searchAethel(fakePhrase, 'word-and-type'); + component.searchAethel(fakePhrase, "word-and-type"); expect(spy).toHaveBeenCalledOnceWith(["/aethel"], { queryParams: { word: "cheeses", type: "cheese->tosti" }, }); diff --git a/frontend/src/app/sample/sample.component.ts b/frontend/src/app/sample/sample.component.ts index 7330533..bbefff1 100644 --- a/frontend/src/app/sample/sample.component.ts +++ b/frontend/src/app/sample/sample.component.ts @@ -2,7 +2,7 @@ import { Component } from "@angular/core"; import { ActivatedRoute, Params, Router } from "@angular/router"; import { AethelApiService } from "../shared/services/aethel-api.service"; import { map } from "rxjs"; -import { AethelMode, LexicalPhrase } from "../shared/types"; +import { AethelMode, AethelDetailPhrase } from "../shared/types"; import { isNonNull } from "../shared/operators/IsNonNull"; import { faArrowLeft } from "@fortawesome/free-solid-svg-icons"; import { Location } from "@angular/common"; @@ -31,12 +31,12 @@ export class SampleComponent { private location: Location, ) {} - public searchAethel(phrase: LexicalPhrase, mode: AethelMode): void { + public searchAethel(phrase: AethelDetailPhrase, mode: AethelMode): void { const queryParams = this.formatQueryParams(phrase, mode); this.router.navigate(["/aethel"], { queryParams }); } - public showButtons(items: LexicalPhrase["items"]): boolean { + public showButtons(items: AethelDetailPhrase["items"]): boolean { // Buttons are hidden if the phrase contains too few characters. const combined = items.map((item) => item.word).join(" "); return combined.length > 2; @@ -46,7 +46,10 @@ export class SampleComponent { this.location.back(); } - private formatQueryParams(phrase: LexicalPhrase, mode: AethelMode): Params { + private formatQueryParams( + phrase: AethelDetailPhrase, + mode: AethelMode, + ): Params { const queryParams: Params = {}; if (mode === "word" || mode === "word-and-type") { queryParams["word"] = phrase.items diff --git a/frontend/src/app/shared/types.ts b/frontend/src/app/shared/types.ts index c547915..cb21ee4 100644 --- a/frontend/src/app/shared/types.ts +++ b/frontend/src/app/shared/types.ts @@ -20,9 +20,10 @@ type LexicalItem = { lemma: string; }; -export type LexicalPhrase = { +export type AethelDetailPhrase = { items: LexicalItem[]; type: string; + displayType: string; }; // Should correspond with SpindleResponse dataclass in backend. @@ -32,7 +33,7 @@ export interface SpindleReturn { pdf: string | null; redirect: string | null; term: string | null; - lexical_phrases: LexicalPhrase[]; + lexical_phrases: AethelDetailPhrase[]; proof: Record | null; } @@ -49,11 +50,11 @@ export interface AethelListLexicalItem { } export interface AethelListPhrase { - items: AethelListLexicalItem[] + items: AethelListLexicalItem[]; } export interface AethelListResult { - phrase: AethelListPhrase + phrase: AethelListPhrase; type: string; displayType: string; sampleCount: number; @@ -76,7 +77,7 @@ export interface AethelDetailResult { name: string; term: string; subset: string; - phrases: LexicalPhrase[]; + phrases: AethelDetailPhrase[]; } export interface AethelDetail { diff --git a/frontend/src/app/spindle/spindle.component.ts b/frontend/src/app/spindle/spindle.component.ts index d9366a4..855abba 100644 --- a/frontend/src/app/spindle/spindle.component.ts +++ b/frontend/src/app/spindle/spindle.component.ts @@ -5,7 +5,7 @@ import { ErrorHandlerService } from "../shared/services/error-handler.service"; import { AlertService } from "../shared/services/alert.service"; import { AlertType } from "../shared/components/alert/alert.component"; import { faDownload, faCopy } from "@fortawesome/free-solid-svg-icons"; -import { LexicalPhrase, SpindleMode } from "../shared/types"; +import { AethelDetailPhrase, SpindleMode } from "../shared/types"; import { SpindleApiService } from "../shared/services/spindle-api.service"; import { Subject, filter, map, share, switchMap, takeUntil, timer } from "rxjs"; import { StatusService } from "../shared/services/status.service"; @@ -26,7 +26,7 @@ export class SpindleComponent implements OnInit { }); term: string | null = null; textOutput: TextOutput | null = null; - lexicalPhrases: LexicalPhrase[] = []; + lexicalPhrases: AethelDetailPhrase[] = []; loading$ = this.apiService.loading$; faCopy = faCopy; @@ -37,8 +37,8 @@ export class SpindleComponent implements OnInit { spindleReady$ = timer(0, 5000).pipe( takeUntil(this.stopStatus$), switchMap(() => this.statusService.get()), - map(status => status.spindle), - share() + map((status) => status.spindle), + share(), ); constructor( @@ -46,14 +46,16 @@ export class SpindleComponent implements OnInit { private alertService: AlertService, private errorHandler: ErrorHandlerService, private destroyRef: DestroyRef, - private statusService: StatusService + private statusService: StatusService, ) {} ngOnInit(): void { - this.spindleReady$.pipe( - filter(ready => ready === true), - takeUntilDestroyed(this.destroyRef) - ).subscribe(() => this.stopStatus$.next()); + this.spindleReady$ + .pipe( + filter((ready) => ready === true), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe(() => this.stopStatus$.next()); this.apiService.output$ .pipe(takeUntilDestroyed(this.destroyRef)) From cd720145f8dc702e7c74147a723a8afff4e8819f Mon Sep 17 00:00:00 2001 From: Xander Vertegaal Date: Fri, 30 Aug 2024 11:49:20 +0200 Subject: [PATCH 13/51] Rename serialize_phrases --- backend/aethel_db/views/detail.py | 4 ++-- backend/spindle/utils.py | 4 ++-- backend/spindle/views.py | 9 ++++----- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/backend/aethel_db/views/detail.py b/backend/aethel_db/views/detail.py index b857b64..49f917e 100644 --- a/backend/aethel_db/views/detail.py +++ b/backend/aethel_db/views/detail.py @@ -4,7 +4,7 @@ from django.http import HttpRequest, JsonResponse from rest_framework import status from rest_framework.views import APIView -from spindle.utils import serialize_phrases_with_infix_notation +from spindle.utils import serialize_phrases from aethel.frontend import Sample from aethel_db.models import dataset @@ -59,7 +59,7 @@ def parse_sample(self, sample: Sample) -> None: name=sample.name, term=str(sample.proof.term), subset=sample.subset, - phrases=serialize_phrases_with_infix_notation(sample.lexical_phrases), + phrases=serialize_phrases(sample.lexical_phrases), ) def json_response(self) -> JsonResponse: diff --git a/backend/spindle/utils.py b/backend/spindle/utils.py index f5793a9..159fe68 100644 --- a/backend/spindle/utils.py +++ b/backend/spindle/utils.py @@ -2,13 +2,13 @@ from aethel.mill.types import type_prefix, type_repr -def serialize_phrases_with_infix_notation( +def serialize_phrases( lexical_phrases: list[LexicalPhrase], ) -> list[dict[str, str]]: """ Serializes a list of LexicalPhrases in a human-readable infix notation that is already available in Æthel in Type.__repr__ or type_repr(). This is used to display the types in the frontend. - The standard JSON serialization of phrases uses a prefix notation for types, which is good for data-exchange purposes (easier parsing) but less ideal for human consumption. This notation will be used to query. + The standard JSON serialization of phrases uses a prefix notation for types, which is good for data-exchange purposes (easier parsing) but less ideal for human consumption. This notation is used to query. """ return [ dict( diff --git a/backend/spindle/views.py b/backend/spindle/views.py index 5581dfe..635a05f 100644 --- a/backend/spindle/views.py +++ b/backend/spindle/views.py @@ -22,7 +22,7 @@ serial_proof_to_json, ) -from spindle.utils import serialize_phrases_with_infix_notation +from spindle.utils import serialize_phrases http = urllib3.PoolManager() @@ -31,7 +31,6 @@ Mode = Literal["latex", "pdf", "overleaf", "term-table", "proof"] - class SpindleErrorSource(Enum): INPUT = "input" SPINDLE = "spindle" @@ -193,7 +192,7 @@ def overleaf_redirect(self, latex: str) -> JsonResponse: def term_table_response(self, parsed: ParserResponse) -> JsonResponse: """Return the term and the lexical phrases as a JSON response.""" - phrases = serialize_phrases_with_infix_notation(parsed.lexical_phrases) + phrases = serialize_phrases(parsed.lexical_phrases) return SpindleResponse( term=str(parsed.proof.term), lexical_phrases=phrases, @@ -210,10 +209,10 @@ def spindle_status(): try: r = http.request( method="GET", - url=settings.SPINDLE_URL + '/status/', + url=settings.SPINDLE_URL + "/status/", headers={"Content-Type": "application/json"}, timeout=1, - retries=False + retries=False, ) return r.status < 400 except Exception: From 35dd308e463283671040103507d95056b6893813 Mon Sep 17 00:00:00 2001 From: Xander Vertegaal Date: Fri, 30 Aug 2024 11:49:37 +0200 Subject: [PATCH 14/51] Camelize displayType for Spindle --- backend/spindle/views.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/backend/spindle/views.py b/backend/spindle/views.py index 635a05f..89452f6 100644 --- a/backend/spindle/views.py +++ b/backend/spindle/views.py @@ -51,6 +51,16 @@ class SpindleResponse: def json_response(self) -> JsonResponse: # TODO: set HTTP error code when error is not None + + # Convert display_type to displayType for frontend. + camelized = [ + { + **{k: v for k, v in phrase.items() if k != "display_type"}, + "displayType": phrase["display_type"], + } + for phrase in self.lexical_phrases + ] + return JsonResponse( { "latex": self.latex, @@ -58,7 +68,7 @@ def json_response(self) -> JsonResponse: "redirect": self.redirect, "error": self.error.value if self.error else None, "term": self.term, - "lexical_phrases": self.lexical_phrases, + "lexical_phrases": camelized, "proof": self.proof, } ) From aa9dec9411655e899cffa1e8209464d598d09b62 Mon Sep 17 00:00:00 2001 From: Xander Vertegaal Date: Fri, 30 Aug 2024 11:49:52 +0200 Subject: [PATCH 15/51] Use displayType in Spindle frontend --- frontend/src/app/spindle/spindle.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/app/spindle/spindle.component.html b/frontend/src/app/spindle/spindle.component.html index 39ffc61..2ab044e 100644 --- a/frontend/src/app/spindle/spindle.component.html +++ b/frontend/src/app/spindle/spindle.component.html @@ -73,7 +73,7 @@

Term:

{{ item.word }} - +
From ac98dcb2a1abbb9ae1d92c5b691815d21a3a453b Mon Sep 17 00:00:00 2001 From: Xander Vertegaal Date: Fri, 30 Aug 2024 12:08:32 +0200 Subject: [PATCH 16/51] Tidying --- backend/aethel_db/views/detail.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/backend/aethel_db/views/detail.py b/backend/aethel_db/views/detail.py index 49f917e..3744ed8 100644 --- a/backend/aethel_db/views/detail.py +++ b/backend/aethel_db/views/detail.py @@ -67,11 +67,9 @@ def json_response(self) -> JsonResponse: AETHEL_DETAIL_STATUS_CODES[self.error] if self.error else status.HTTP_200_OK ) - result_data = self.result.serialize() if self.result else None - return JsonResponse( { - "result": result_data if self.result else None, + "result": self.result.serialize() if self.result else None, "error": self.error, }, status=status_code, From 6091c22707c7da51170a1aff3ada88c4194ad2e2 Mon Sep 17 00:00:00 2001 From: Xander Vertegaal Date: Fri, 30 Aug 2024 12:17:04 +0200 Subject: [PATCH 17/51] Fix button styling --- .../spindle/export-button/export-button.component.html | 2 +- .../spindle/export-button/export-button.component.scss | 10 +++++----- .../spindle/export-button/export-button.component.ts | 4 ++++ 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/frontend/src/app/spindle/export-button/export-button.component.html b/frontend/src/app/spindle/export-button/export-button.component.html index 62c3289..f758eaf 100644 --- a/frontend/src/app/spindle/export-button/export-button.component.html +++ b/frontend/src/app/spindle/export-button/export-button.component.html @@ -1,5 +1,5 @@ } diff --git a/frontend/src/app/aethel/sample-details/sample-data.component.scss b/frontend/src/app/aethel/sample-details/sample-data.component.scss index d98290d..5a257c3 100644 --- a/frontend/src/app/aethel/sample-details/sample-data.component.scss +++ b/frontend/src/app/aethel/sample-details/sample-data.component.scss @@ -1,9 +1,12 @@ .sample-item { display: flex; - justify-content: space-between; align-items: center; width: 100%; + + a { + margin-left: auto; + } } .sample-item:not(:last-child) { From 2cfa23e70156d15c654954f2c7e821320622f56d Mon Sep 17 00:00:00 2001 From: Xander Vertegaal Date: Mon, 16 Sep 2024 16:56:58 +0200 Subject: [PATCH 22/51] Implement skip parameter with signals --- .../sample-details/sample-data.component.html | 2 +- .../sample-details/sample-data.component.ts | 19 ++++++++++++++++--- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/frontend/src/app/aethel/sample-details/sample-data.component.html b/frontend/src/app/aethel/sample-details/sample-data.component.html index b0a0241..3514cdf 100644 --- a/frontend/src/app/aethel/sample-details/sample-data.component.html +++ b/frontend/src/app/aethel/sample-details/sample-data.component.html @@ -1,6 +1,6 @@