diff --git a/client/src/app/admin/admin.module.ts b/client/src/app/admin/admin.module.ts index 4de003bec..7db7a2cf8 100644 --- a/client/src/app/admin/admin.module.ts +++ b/client/src/app/admin/admin.module.ts @@ -21,7 +21,6 @@ import { adminRouting } from "./admin.routes"; import { AdminComponent } from "./admin.component"; import { AdminOverviewComponent } from "./admin-overview.component"; -import { EditGrammarComponent } from "./edit-grammar.component"; import { LinkGrammarComponent } from "./link-grammar.component"; import { JsonEditor } from "./json-editor.component"; import { JsonSchemaValidationService } from "./json-schema-validation.service"; @@ -36,8 +35,10 @@ import { ErrorListComponent } from "./block-language/error-list.component"; import { OverviewBlockLanguageComponent } from "./block-language/overview-block-language.component"; import { CreateGrammarComponent } from "./grammar/create-grammar.component"; +import { EditGrammarComponent } from "./grammar/edit-grammar.component"; import { OverviewGrammarComponent } from "./grammar/overview-grammar.component"; import { GalleryGrammarComponent } from "./grammar/gallery-grammar.component"; +import { MetaCodeResourceSelectComponent } from "./grammar/meta-code-resource-select.component"; import { OverviewProjectComponent } from "./project/overview-project.component"; @@ -91,6 +92,7 @@ const materialModules = [ AdminNewsEditComponent, ChangeRoles, GalleryGrammarComponent, + MetaCodeResourceSelectComponent, ], providers: [JsonSchemaValidationService], exports: [], diff --git a/client/src/app/admin/admin.routes.ts b/client/src/app/admin/admin.routes.ts index d53a8091f..fcc17bb53 100644 --- a/client/src/app/admin/admin.routes.ts +++ b/client/src/app/admin/admin.routes.ts @@ -2,8 +2,8 @@ import { Routes, RouterModule } from "@angular/router"; import { AdminComponent, adminItems } from "./admin.component"; import { AdminOverviewComponent } from "./admin-overview.component"; -import { EditGrammarComponent } from "./edit-grammar.component"; import { EditBlockLanguageComponent } from "./block-language/edit-block-language.component"; +import { EditGrammarComponent } from "./grammar/edit-grammar.component"; import { OverviewGrammarComponent } from "./grammar/overview-grammar.component"; import { OverviewBlockLanguageComponent } from "./block-language/overview-block-language.component"; import { NavSiteComponent } from "../shared/nav-page.component"; diff --git a/client/src/app/admin/block-language/create-block-language.component.ts b/client/src/app/admin/block-language/create-block-language.component.ts index cbb8b6a13..dbb4614fb 100644 --- a/client/src/app/admin/block-language/create-block-language.component.ts +++ b/client/src/app/admin/block-language/create-block-language.component.ts @@ -10,8 +10,9 @@ import { generateBlockLanguage } from "../../shared/block/generator/generator"; import { ServerApiService, - BlockLanguageDataService, - GrammarDataService, + ListBlockLanguageDataService, + IndividualGrammarDataService, + ListGrammarDataService, } from "../../shared/serverdata"; /** @@ -39,19 +40,15 @@ export class CreateBlockLanguageComponent { useSlug = false; constructor( - private _serverData: BlockLanguageDataService, - private _grammarData: GrammarDataService, + private _serverData: ListBlockLanguageDataService, + private _grammarData: IndividualGrammarDataService, + private _grammarList: ListGrammarDataService, private _serverApi: ServerApiService, private _http: HttpClient, private _router: Router ) {} - /** - * Grammars that may be used for creation - */ - public get availableGrammars() { - return this._grammarData.list; - } + readonly availableGrammars = this._grammarList.list; /** * Attempts to create the specified block language diff --git a/client/src/app/admin/block-language/edit-block-language.component.ts b/client/src/app/admin/block-language/edit-block-language.component.ts index 536f69921..6bfb053b6 100644 --- a/client/src/app/admin/block-language/edit-block-language.component.ts +++ b/client/src/app/admin/block-language/edit-block-language.component.ts @@ -7,8 +7,8 @@ import { import { ActivatedRoute, Router } from "@angular/router"; import { - GrammarDataService, - BlockLanguageDataService, + ListGrammarDataService, + MutateBlockLanguageService, } from "../../shared/serverdata"; import { ToolbarService } from "../../shared/toolbar.service"; @@ -25,8 +25,8 @@ export class EditBlockLanguageComponent implements AfterViewInit { constructor( private _activatedRoute: ActivatedRoute, private _router: Router, - private _grammarData: GrammarDataService, - private _blockLanguageData: BlockLanguageDataService, + private _grammarData: ListGrammarDataService, + private _mutateBlockLanguageData: MutateBlockLanguageService, private _current: EditBlockLanguageService, private _toolbarService: ToolbarService ) {} @@ -117,7 +117,7 @@ export class EditBlockLanguageComponent implements AfterViewInit { * User has decided to delete. */ async onDelete() { - await this._blockLanguageData.deleteSingle(this.editedSubject.id); + await this._mutateBlockLanguageData.deleteSingle(this.editedSubject.id); this._router.navigate([".."], { relativeTo: this._activatedRoute }); } diff --git a/client/src/app/admin/block-language/edit-block-language.service.ts b/client/src/app/admin/block-language/edit-block-language.service.ts index 9ae9a73ea..14ca177c7 100644 --- a/client/src/app/admin/block-language/edit-block-language.service.ts +++ b/client/src/app/admin/block-language/edit-block-language.service.ts @@ -7,8 +7,9 @@ import { BehaviorSubject } from "rxjs"; import { switchMap, map, first, filter, flatMap } from "rxjs/operators"; import { - GrammarDataService, - BlockLanguageDataService, + IndividualGrammarDataService, + IndividualBlockLanguageDataService, + MutateBlockLanguageService, } from "../../shared/serverdata"; import { BlockLanguageDescription } from "../../shared/block/block-language.description"; import { @@ -34,8 +35,9 @@ export class EditBlockLanguageService { public prettyPrintedBlockLanguage = ""; constructor( - private _serverData: BlockLanguageDataService, - private _grammarData: GrammarDataService, + private _individualBlockLanguageData: IndividualBlockLanguageDataService, + private _mutateBlockLanguageData: MutateBlockLanguageService, + private _individualGrammarData: IndividualGrammarDataService, private _activatedRoute: ActivatedRoute, private _snackBar: MatSnackBar, private _title: Title @@ -44,7 +46,9 @@ export class EditBlockLanguageService { this._activatedRoute.paramMap .pipe( map((params: ParamMap) => params.get("blockLanguageId")), - switchMap((id: string) => this._serverData.getSingle(id).pipe(first())) + switchMap((id: string) => + this._individualBlockLanguageData.getSingle(id).pipe(first()) + ) ) .subscribe((blockLanguage) => { this._editedSubject.next(blockLanguage); @@ -67,7 +71,9 @@ export class EditBlockLanguageService { * The grammar that is the basis for this block language. */ readonly baseGrammar = this._editedSubject.pipe( - flatMap((blockLang) => this._grammarData.getSingle(blockLang.grammarId)) + flatMap((blockLang) => + this._individualGrammarData.getSingle(blockLang.grammarId) + ) ); /** @@ -121,7 +127,7 @@ export class EditBlockLanguageService { // And do something meaningful if they are if (this.generatorErrors.length === 0) { // Fetch the actual grammar that should be used - this._grammarData + this._individualGrammarData .getSingle(this.editedSubject.grammarId, true) .pipe(first()) .subscribe((g) => { @@ -170,7 +176,7 @@ export class EditBlockLanguageService { * Saves the current state of the block language */ save() { - this._serverData.updateBlockLanguage(this.editedSubject); + this._mutateBlockLanguageData.updateSingle(this.editedSubject); } /** diff --git a/client/src/app/admin/block-language/edit-single-trait-scope.component.ts b/client/src/app/admin/block-language/edit-single-trait-scope.component.ts index f140b9388..6fd0347fb 100644 --- a/client/src/app/admin/block-language/edit-single-trait-scope.component.ts +++ b/client/src/app/admin/block-language/edit-single-trait-scope.component.ts @@ -10,7 +10,7 @@ import { MatAutocompleteSelectedEvent } from "@angular/material/autocomplete"; import { first, map, tap } from "rxjs/operators"; -import { GrammarDataService } from "../../shared/serverdata"; +import { IndividualGrammarDataService } from "../../shared/serverdata"; import { ScopeTraitAdd } from "../../shared/block/generator/traits.description"; import { FullNodeAttributeDescription, @@ -48,7 +48,7 @@ interface TargetBlock { export class EditSingleTraitScopeComponent implements OnInit, OnChanges { constructor( private _editedBlockLanguageService: EditBlockLanguageService, - private _grammarData: GrammarDataService + private _individualGrammarData: IndividualGrammarDataService ) {} /** @@ -81,7 +81,7 @@ export class EditSingleTraitScopeComponent implements OnInit, OnChanges { * Used to get hold of the grammar that is used by this block language. */ ngOnInit() { - this._grammarData + this._individualGrammarData .getSingle(this._editedBlockLanguageService.editedSubject.grammarId) .pipe(first()) .subscribe((g) => { diff --git a/client/src/app/admin/block-language/overview-block-language.component.spec.ts b/client/src/app/admin/block-language/overview-block-language.component.spec.ts new file mode 100644 index 000000000..ae93d8a3e --- /dev/null +++ b/client/src/app/admin/block-language/overview-block-language.component.spec.ts @@ -0,0 +1,172 @@ +import { FormsModule } from "@angular/forms"; +import { RouterTestingModule } from "@angular/router/testing"; +import { TestBed } from "@angular/core/testing"; +import { NoopAnimationsModule } from "@angular/platform-browser/animations"; +import { + HttpClientTestingModule, + HttpTestingController, +} from "@angular/common/http/testing"; +import { MatSnackBarModule } from "@angular/material/snack-bar"; +import { MatTableModule } from "@angular/material/table"; +import { MatPaginatorModule } from "@angular/material/paginator"; +import { MatSortModule } from "@angular/material/sort"; +import { PortalModule } from "@angular/cdk/portal"; + +import { first } from "rxjs/operators"; + +import { ServerApiService, ToolbarService } from "../../shared"; +import { + ListBlockLanguageDataService, + MutateBlockLanguageService, +} from "../../shared/serverdata"; +import { DefaultValuePipe } from "../../shared/default-value.pipe"; +import { + provideBlockLanguageList, + buildBlockLanguage, +} from "../../editor/spec-util"; + +import { OverviewBlockLanguageComponent } from "./overview-block-language.component"; +import { PaginatorTableComponent } from "../../shared/table/paginator-table.component"; + +describe("OverviewBlockLanguageComponent", () => { + async function createComponent() { + await TestBed.configureTestingModule({ + imports: [ + FormsModule, + NoopAnimationsModule, + MatSnackBarModule, + MatTableModule, + MatPaginatorModule, + MatSortModule, + PortalModule, + HttpClientTestingModule, + RouterTestingModule.withRoutes([]), + ], + providers: [ + ToolbarService, + ServerApiService, + ListBlockLanguageDataService, + MutateBlockLanguageService, + ], + declarations: [ + OverviewBlockLanguageComponent, + DefaultValuePipe, + PaginatorTableComponent, + ], + }).compileComponents(); + + let fixture = TestBed.createComponent(OverviewBlockLanguageComponent); + let component = fixture.componentInstance; + fixture.detectChanges(); + + const httpTesting = TestBed.inject(HttpTestingController); + const serverApi = TestBed.inject(ServerApiService); + + return { + fixture, + component, + element: fixture.nativeElement as HTMLElement, + httpTesting, + serverApi, + }; + } + + it(`can be instantiated`, async () => { + const t = await createComponent(); + + expect(t.component).toBeDefined(); + }); + + it(`Displays a loading indicator (or not)`, async () => { + const t = await createComponent(); + + const initialLoading = await t.component.blockLanguages.listCache.inProgress + .pipe(first()) + .toPromise(); + expect(initialLoading).toBe(true); + + provideBlockLanguageList([]); + + const afterResponse = await t.component.blockLanguages.listCache.inProgress + .pipe(first()) + .toPromise(); + expect(afterResponse).toBe(false); + }); + + it(`Displays an empty list`, async () => { + const t = await createComponent(); + + provideBlockLanguageList([]); + + t.fixture.detectChanges(); + await t.fixture.whenRenderingDone(); + }); + + it(`Displays a list with a single element`, async () => { + const t = await createComponent(); + + const i1 = buildBlockLanguage({ name: "B1" }); + provideBlockLanguageList([i1]); + + t.fixture.detectChanges(); + await t.fixture.whenRenderingDone(); + + const tableElement = t.element.querySelector("table"); + const i1Row = tableElement.querySelector("tbody > tr"); + + expect(i1Row.textContent).toMatch(i1.name); + expect(i1Row.textContent).toMatch(i1.id); + }); + + it(`reloads data on refresh`, async () => { + const t = await createComponent(); + + const i1 = buildBlockLanguage({ name: "B1" }); + provideBlockLanguageList([i1]); + + const initialData = await t.component.blockLanguages.list + .pipe(first()) + .toPromise(); + expect(initialData).toEqual([i1]); + + t.component.onRefresh(); + provideBlockLanguageList([]); + + const refreshedData = await t.component.blockLanguages.list + .pipe(first()) + .toPromise(); + expect(refreshedData).toEqual([]); + }); + + it(`Triggers deletion`, async () => { + const t = await createComponent(); + + const i1 = buildBlockLanguage({ name: "B1" }); + provideBlockLanguageList([i1]); + + t.fixture.detectChanges(); + await t.fixture.whenRenderingDone(); + + const tableElement = t.element.querySelector("table"); + const i1Row = tableElement.querySelector("tbody > tr"); + const i1Delete = i1Row.querySelector( + "button[data-spec=delete]" + ) as HTMLButtonElement; + + i1Delete.click(); + + t.httpTesting + .expectOne({ + method: "DELETE", + url: t.serverApi.individualBlockLanguageUrl(i1.id), + }) + .flush(""); + + provideBlockLanguageList([]); + + const refreshedData = await t.component.blockLanguages.list + .pipe(first()) + .toPromise(); + expect(refreshedData).toEqual([]); + }); +}); diff --git a/client/src/app/admin/block-language/overview-block-language.component.ts b/client/src/app/admin/block-language/overview-block-language.component.ts index 066eaef78..3bb943ebf 100644 --- a/client/src/app/admin/block-language/overview-block-language.component.ts +++ b/client/src/app/admin/block-language/overview-block-language.component.ts @@ -1,20 +1,31 @@ import { Component, ViewChild, TemplateRef, OnInit } from "@angular/core"; +import { MatSort } from "@angular/material/sort"; import { ToolbarService } from "../../shared"; -import { BlockLanguageDataService } from "../../shared/serverdata"; +import { BlockLanguageListDescription } from "../../shared/block/block-language.description"; +import { + ListBlockLanguageDataService, + MutateBlockLanguageService, +} from "../../shared/serverdata"; /** * Shows All block languages that are known to the server. */ @Component({ templateUrl: "./templates/overview-block-language.html", + providers: [ListBlockLanguageDataService], }) export class OverviewBlockLanguageComponent implements OnInit { + // Angular Material UI to sort by different columns + @ViewChild(MatSort, { static: true }) + sort: MatSort; + @ViewChild("toolbarItems", { static: true }) toolbarItems: TemplateRef; constructor( - private _serverData: BlockLanguageDataService, + readonly blockLanguages: ListBlockLanguageDataService, + private _mutate: MutateBlockLanguageService, private _toolbarService: ToolbarService ) {} @@ -22,11 +33,21 @@ export class OverviewBlockLanguageComponent implements OnInit { this._toolbarService.addItem(this.toolbarItems); } - public get availableBlockLanguages() { - return this._serverData.listCache; + async deleteBlockLanguage(id: string) { + await this._mutate.deleteSingle(id); } - public deleteBlockLanguage(id: string) { - this._serverData.deleteBlockLanguage(id); + /** + * User wants to see a refreshed dataset. + */ + onRefresh() { + this.blockLanguages.listCache.refresh(); } + + displayedColumns: ( + | keyof BlockLanguageListDescription + | "generator" + | "actions" + | "grammar" + )[] = ["name", "slug", "id", "grammar", "actions", "generator"]; } diff --git a/client/src/app/admin/block-language/templates/overview-block-language.html b/client/src/app/admin/block-language/templates/overview-block-language.html index 368a03e81..92e234b79 100644 --- a/client/src/app/admin/block-language/templates/overview-block-language.html +++ b/client/src/app/admin/block-language/templates/overview-block-language.html @@ -1,36 +1,44 @@ - - -

- - Blocksprachen -

- - - - - - - - - - - - - + - - - - + + + + + + + + + + + + + - + - -
NameSlugIDGrammatikGeneratorAktionen
- {{ blockLanguage.name }} + + + Name + + {{ blockLanguage.name }} + {{ blockLanguage.slug | defaultValue }}{{ blockLanguage.id }} + + + Slug + {{ blockLanguage.slug | defaultValue }} + ID + {{ blockLanguage.id }} + Grammatik + + Generator @@ -38,16 +46,20 @@

+ + + Aktionen
+ + diff --git a/client/src/app/admin/grammar/create-grammar.component.spec.ts b/client/src/app/admin/grammar/create-grammar.component.spec.ts new file mode 100644 index 000000000..4660b8b4c --- /dev/null +++ b/client/src/app/admin/grammar/create-grammar.component.spec.ts @@ -0,0 +1,105 @@ +import { FormsModule } from "@angular/forms"; +import { RouterTestingModule } from "@angular/router/testing"; +import { TestBed } from "@angular/core/testing"; +import { NoopAnimationsModule } from "@angular/platform-browser/animations"; +import { + HttpClientTestingModule, + HttpTestingController, +} from "@angular/common/http/testing"; +import { MatSnackBarModule } from "@angular/material/snack-bar"; +import { MatTableModule } from "@angular/material/table"; +import { PortalModule } from "@angular/cdk/portal"; + +import { first } from "rxjs/operators"; + +import { + LanguageService, + ServerApiService, + ToolbarService, +} from "../../shared"; +import { + ListGrammarDataService, + MutateGrammarService, +} from "../../shared/serverdata"; +import { DefaultValuePipe } from "../../shared/default-value.pipe"; +import { provideGrammarList, buildGrammar } from "../../editor/spec-util"; + +import { CreateGrammarComponent } from "./create-grammar.component"; + +describe("CreateGrammarComponent", () => { + async function createComponent() { + await TestBed.configureTestingModule({ + imports: [ + FormsModule, + NoopAnimationsModule, + MatSnackBarModule, + MatTableModule, + PortalModule, + HttpClientTestingModule, + RouterTestingModule.withRoutes([]), + ], + providers: [ + ToolbarService, + ServerApiService, + LanguageService, + ListGrammarDataService, + MutateGrammarService, + ], + declarations: [CreateGrammarComponent, DefaultValuePipe], + }).compileComponents(); + + let fixture = TestBed.createComponent(CreateGrammarComponent); + let component = fixture.componentInstance; + fixture.detectChanges(); + + const httpTesting = TestBed.inject(HttpTestingController); + const serverApi = TestBed.inject(ServerApiService); + + return { + fixture, + component, + element: fixture.nativeElement as HTMLElement, + httpTesting, + serverApi, + }; + } + + it(`can be instantiated`, async () => { + const t = await createComponent(); + + expect(t.component).toBeDefined(); + }); + it(`create grammar`, async () => { + const t = await createComponent(); + const g1 = buildGrammar({ name: "G1Test", programmingLanguageId: "sql" }); + + await t.fixture.whenRenderingDone(); + + const nameInput: HTMLInputElement = t.element.querySelector( + "input[data-spec=nameInput]" + ); + const plSelect: HTMLSelectElement = t.element.querySelector( + "select[data-spec=programmingLanguageSelect]" + ); + const createButton: HTMLButtonElement = t.element.querySelector( + "button[data-spec=submit]" + ); + + // simulate user entering a new name into the input box + nameInput.value = "G1Test"; + plSelect.value = plSelect.options[0].value; + + // dispatch a DOM event so that Angular learns of input value change. + // use newEvent utility function (not provided by Angular) for better browser compatibility + nameInput.dispatchEvent(new Event("input")); + plSelect.dispatchEvent(new Event("change")); + createButton.click(); + + t.fixture.detectChanges(); + + t.httpTesting + .expectOne({ method: "POST", url: t.serverApi.createGrammarUrl() }) + .flush(""); + // t.httpTesting.expectOne({method: "GET", url: "/admin/grammars/undefined"}).flush(""); + }); +}); diff --git a/client/src/app/admin/grammar/create-grammar.component.ts b/client/src/app/admin/grammar/create-grammar.component.ts index 05467c937..f6de9d22c 100644 --- a/client/src/app/admin/grammar/create-grammar.component.ts +++ b/client/src/app/admin/grammar/create-grammar.component.ts @@ -2,7 +2,10 @@ import { Component } from "@angular/core"; import { HttpClient } from "@angular/common/http"; import { Router } from "@angular/router"; -import { ServerApiService, GrammarDataService } from "../../shared/serverdata"; +import { + ServerApiService, + ListGrammarDataService, +} from "../../shared/serverdata"; import { GrammarDescription } from "../../shared/syntaxtree"; import { LanguageService } from "../../shared/language.service"; @@ -25,7 +28,7 @@ export class CreateGrammarComponent { }; constructor( - private _serverData: GrammarDataService, + private _serverData: ListGrammarDataService, private _serverApi: ServerApiService, private _languageService: LanguageService, private _http: HttpClient, diff --git a/client/src/app/admin/edit-grammar.component.ts b/client/src/app/admin/grammar/edit-grammar.component.ts similarity index 79% rename from client/src/app/admin/edit-grammar.component.ts rename to client/src/app/admin/grammar/edit-grammar.component.ts index 183ed7911..45516d14d 100644 --- a/client/src/app/admin/edit-grammar.component.ts +++ b/client/src/app/admin/grammar/edit-grammar.component.ts @@ -6,13 +6,17 @@ import { Title } from "@angular/platform-browser"; import { from } from "rxjs"; import { switchMap, map } from "rxjs/operators"; -import { ToolbarService } from "../shared/toolbar.service"; -import { CachedRequest, GrammarDataService } from "../shared/serverdata"; -import { ServerApiService } from "../shared/serverdata/serverapi.service"; -import { prettyPrintGrammar } from "../shared/syntaxtree/prettyprint"; -import { GrammarDescription, QualifiedTypeName } from "../shared/syntaxtree"; -import { BlockLanguageListDescription } from "../shared/block/block-language.description"; -import { getAllTypes } from "../shared/syntaxtree/grammar-util"; +import { ToolbarService } from "../../shared/toolbar.service"; +import { + CachedRequest, + IndividualGrammarDataService, + MutateGrammarService, +} from "../../shared/serverdata"; +import { ServerApiService } from "../../shared/serverdata/serverapi.service"; +import { prettyPrintGrammar } from "../../shared/syntaxtree/prettyprint"; +import { GrammarDescription, QualifiedTypeName } from "../../shared/syntaxtree"; +import { BlockLanguageListDescription } from "../../shared/block/block-language.description"; +import { getAllTypes } from "../../shared/syntaxtree/grammar-util"; @Component({ templateUrl: "templates/edit-grammar.html", @@ -36,7 +40,8 @@ export class EditGrammarComponent implements OnInit { private _router: Router, private _http: HttpClient, private _serverApi: ServerApiService, - private _grammarData: GrammarDataService, + private _individualGrammarData: IndividualGrammarDataService, + private _mutateGrammarData: MutateGrammarService, private _title: Title, private _toolbarService: ToolbarService ) {} @@ -48,7 +53,7 @@ export class EditGrammarComponent implements OnInit { .pipe( map((params: ParamMap) => params.get("grammarId")), switchMap((id: string) => - from(this._grammarData.getLocal(id, "request")) + from(this._individualGrammarData.getLocal(id, "request")) ) ) .subscribe((g) => { @@ -58,7 +63,7 @@ export class EditGrammarComponent implements OnInit { this._title.setTitle(`Grammar "${g.name}" - Admin - BlattWerkzeug`); // We want a local copy of the resource that is being edited available "globally" - this._grammarData.setLocal(g); + this._individualGrammarData.setLocal(g); }); // Always grab fresh related block languages @@ -84,7 +89,7 @@ export class EditGrammarComponent implements OnInit { * User has decided to save. */ onSave() { - this._grammarData.updateSingle(this.grammar); + this._mutateGrammarData.updateSingle(this.grammar); } get grammarRoot() { @@ -118,7 +123,7 @@ export class EditGrammarComponent implements OnInit { * User has decided to delete. */ async onDelete() { - await this._grammarData.deleteSingle(this.grammar.id); + await this._mutateGrammarData.deleteSingle(this.grammar.id); this._router.navigate([".."], { relativeTo: this._activatedRoute }); } diff --git a/client/src/app/admin/grammar/gallery-grammar.component.ts b/client/src/app/admin/grammar/gallery-grammar.component.ts index ad7514f0f..6eeeef698 100644 --- a/client/src/app/admin/grammar/gallery-grammar.component.ts +++ b/client/src/app/admin/grammar/gallery-grammar.component.ts @@ -5,7 +5,10 @@ import { ActivatedRoute, ParamMap } from "@angular/router"; import { Observable, from } from "rxjs"; import { map, switchMap, startWith } from "rxjs/operators"; -import { GrammarDataService, ServerApiService } from "../../shared/serverdata"; +import { + IndividualGrammarDataService, + ServerApiService, +} from "../../shared/serverdata"; import { GrammarDescription, CodeResource, @@ -24,7 +27,7 @@ export class GalleryGrammarComponent implements OnInit { constructor( private _http: HttpClient, private _activatedRoute: ActivatedRoute, - private _grammarData: GrammarDataService, + private _grammarData: IndividualGrammarDataService, private _serverApi: ServerApiService, private _resourceReferences: ResourceReferencesService ) {} @@ -53,7 +56,7 @@ export class GalleryGrammarComponent implements OnInit { switchMap((id) => this.createGrammarCodeResourceGalleryRequest(id)), map((descriptions) => descriptions - .slice(0, 1) + .slice(0, 10) .map((d) => new CodeResource(d, this._resourceReferences)) ), startWith([]) diff --git a/client/src/app/admin/grammar/meta-code-resource-list.service.ts b/client/src/app/admin/grammar/meta-code-resource-list.service.ts new file mode 100644 index 000000000..fc4a6b229 --- /dev/null +++ b/client/src/app/admin/grammar/meta-code-resource-list.service.ts @@ -0,0 +1,20 @@ +import { Injectable } from "@angular/core"; +import { HttpClient } from "@angular/common/http"; + +import { ServerApiService } from "../../shared"; +import { ListData } from "../../shared/serverdata"; + +import { MetaCodeResourceListDescription } from "./meta-code-resource.description"; + +@Injectable() +export class ListMetaCodeResourcesService { + constructor( + private _httpClient: HttpClient, + private _serverApi: ServerApiService + ) {} + + readonly metaCodeResources = new ListData( + this._httpClient, + this._serverApi.getMetaCodeResourceListUrl() + ); +} diff --git a/client/src/app/admin/grammar/meta-code-resource-select.component.spec.ts b/client/src/app/admin/grammar/meta-code-resource-select.component.spec.ts new file mode 100644 index 000000000..6fd9a370b --- /dev/null +++ b/client/src/app/admin/grammar/meta-code-resource-select.component.spec.ts @@ -0,0 +1,110 @@ +import { TestBed } from "@angular/core/testing"; +import { + HttpClientTestingModule, + HttpTestingController, +} from "@angular/common/http/testing"; +import { FormsModule } from "@angular/forms"; + +import { MetaCodeResourceSelectComponent } from "./meta-code-resource-select.component"; + +import { ServerApiService } from "../../shared"; +import { MetaCodeResourceListDescription } from "./meta-code-resource.description"; + +describe("MetaCodeResourceSelect", () => { + async function createComponent(preSelectedId = undefined) { + await TestBed.configureTestingModule({ + imports: [HttpClientTestingModule, FormsModule], + providers: [ServerApiService], + declarations: [MetaCodeResourceSelectComponent], + }).compileComponents(); + + let fixture = TestBed.createComponent(MetaCodeResourceSelectComponent); + let component = fixture.componentInstance; + + if (preSelectedId) { + component.selectedCodeResourceId = preSelectedId; + } + + fixture.detectChanges(); + + return { + fixture, + component, + element: fixture.nativeElement as HTMLElement, + httpTesting: TestBed.inject(HttpTestingController), + serverApi: TestBed.inject(ServerApiService), + }; + } + + it(`can be instantiated`, async () => { + const fixture = await createComponent(); + expect(fixture.component).toBeDefined(); + }); + + it(`Shows an empty list`, async () => { + const fixture = await createComponent(); + + fixture.httpTesting + .expectOne(fixture.serverApi.getMetaCodeResourceListUrl()) + .flush([]); + + fixture.fixture.detectChanges(); + await fixture.fixture.whenRenderingDone(); + + const selectElement = fixture.element.querySelector("select"); + expect(selectElement.value).toBeFalsy(); + expect(selectElement.children.length).toEqual(1); + }); + + it(`Shows a list with a single unselected item`, async () => { + const fixture = await createComponent(); + + const response: MetaCodeResourceListDescription[] = [ + { + id: "0", + name: "zero", + }, + ]; + + fixture.httpTesting + .expectOne(fixture.serverApi.getMetaCodeResourceListUrl()) + .flush(response); + + fixture.fixture.detectChanges(); + await fixture.fixture.whenRenderingDone(); + + const selectElement = fixture.element.querySelector("select"); + expect(selectElement.selectedIndex).toEqual(-1); + expect(selectElement.children.length).toEqual(2); + expect(selectElement.children[1].textContent.trim()).toEqual( + response[0].name + ); + }); + + it(`Pre-selects in a list with a single item`, async () => { + const response: MetaCodeResourceListDescription[] = [ + { + id: "0000", + name: "zero", + }, + ]; + + const fixture = await createComponent(response[0].id); + expect(fixture.component.selectedCodeResourceId).toEqual(response[0].id); + + fixture.httpTesting + .expectOne(fixture.serverApi.getMetaCodeResourceListUrl()) + .flush(response); + + fixture.fixture.detectChanges(); + await fixture.fixture.whenRenderingDone(); + + const selectElement = fixture.element.querySelector("select"); + + expect(selectElement.selectedIndex).toEqual(1); + expect(selectElement.children.length).toEqual(2); + expect(selectElement.children[1].textContent.trim()).toEqual( + response[0].name + ); + }); +}); diff --git a/client/src/app/admin/grammar/meta-code-resource-select.component.ts b/client/src/app/admin/grammar/meta-code-resource-select.component.ts new file mode 100644 index 000000000..fd6d45d7c --- /dev/null +++ b/client/src/app/admin/grammar/meta-code-resource-select.component.ts @@ -0,0 +1,41 @@ +import { Component, EventEmitter, Output, Input } from "@angular/core"; + +import { ListMetaCodeResourcesService } from "./meta-code-resource-list.service"; + +/** + * Allows the selection of a single meta code resource (or none). + */ +@Component({ + templateUrl: `templates/meta-code-resource-select.html`, + selector: `meta-code-resource-select`, + providers: [ListMetaCodeResourcesService], +}) +export class MetaCodeResourceSelectComponent { + @Output() + selectedCodeResourceIdChange = new EventEmitter(); + + constructor(private _list: ListMetaCodeResourcesService) {} + + /** + * The code resources that are available. + */ + readonly metaCodeResources$ = this._list.metaCodeResources.list; + + private _selectedCodeResourceId: string; + + /** + * @return The code resource id that is currently set + */ + @Input() + get selectedCodeResourceId() { + return this._selectedCodeResourceId; + } + + /** + * Changes the selected code resource id and informs listeners. + */ + set selectedCodeResourceId(val: string) { + this._selectedCodeResourceId = val; + this.selectedCodeResourceIdChange.emit(val); + } +} diff --git a/client/src/app/admin/grammar/meta-code-resource.description.ts b/client/src/app/admin/grammar/meta-code-resource.description.ts new file mode 100644 index 000000000..07368e66e --- /dev/null +++ b/client/src/app/admin/grammar/meta-code-resource.description.ts @@ -0,0 +1,9 @@ +import { IdentifiableResourceDescription } from "../../shared/resource.description"; + +/** + * List data for a code resource that describes a grammar. + */ +export interface MetaCodeResourceListDescription + extends IdentifiableResourceDescription { + name: string; +} diff --git a/client/src/app/admin/grammar/overview-grammar.component.spec.ts b/client/src/app/admin/grammar/overview-grammar.component.spec.ts new file mode 100644 index 000000000..b3d9d2184 --- /dev/null +++ b/client/src/app/admin/grammar/overview-grammar.component.spec.ts @@ -0,0 +1,176 @@ +import { FormsModule } from "@angular/forms"; +import { RouterTestingModule } from "@angular/router/testing"; +import { TestBed } from "@angular/core/testing"; +import { NoopAnimationsModule } from "@angular/platform-browser/animations"; +import { + HttpClientTestingModule, + HttpTestingController, +} from "@angular/common/http/testing"; +import { MatSnackBarModule } from "@angular/material/snack-bar"; +import { MatTableModule } from "@angular/material/table"; +import { MatPaginatorModule } from "@angular/material/paginator"; +import { MatSortModule } from "@angular/material/sort"; +import { PortalModule } from "@angular/cdk/portal"; + +import { first } from "rxjs/operators"; + +import { + ServerApiService, + ToolbarService, + LanguageService, +} from "../../shared"; +import { + ListGrammarDataService, + MutateGrammarService, +} from "../../shared/serverdata"; +import { DefaultValuePipe } from "../../shared/default-value.pipe"; +import { PaginatorTableComponent } from "../../shared/table/paginator-table.component"; +import { provideGrammarList, buildGrammar } from "../../editor/spec-util"; + +import { OverviewGrammarComponent } from "./overview-grammar.component"; +import { CreateGrammarComponent } from "./create-grammar.component"; + +describe("OverviewGrammarComponent", () => { + async function createComponent() { + await TestBed.configureTestingModule({ + imports: [ + FormsModule, + NoopAnimationsModule, + MatSnackBarModule, + MatPaginatorModule, + MatSortModule, + MatTableModule, + PortalModule, + HttpClientTestingModule, + RouterTestingModule.withRoutes([]), + ], + providers: [ + ToolbarService, + ServerApiService, + ListGrammarDataService, + MutateGrammarService, + LanguageService, + ], + declarations: [ + CreateGrammarComponent, + OverviewGrammarComponent, + DefaultValuePipe, + PaginatorTableComponent, + ], + }).compileComponents(); + + let fixture = TestBed.createComponent(OverviewGrammarComponent); + let component = fixture.componentInstance; + fixture.detectChanges(); + + const httpTesting = TestBed.inject(HttpTestingController); + const serverApi = TestBed.inject(ServerApiService); + + return { + fixture, + component, + element: fixture.nativeElement as HTMLElement, + httpTesting, + serverApi, + }; + } + + it(`can be instantiated`, async () => { + const t = await createComponent(); + + expect(t.component).toBeDefined(); + }); + + it(`Displays a loading indicator (or not)`, async () => { + const t = await createComponent(); + + const initialLoading = await t.component.grammars.listCache.inProgress + .pipe(first()) + .toPromise(); + expect(initialLoading).toBe(true); + + provideGrammarList([]); + + const afterResponse = await t.component.grammars.listCache.inProgress + .pipe(first()) + .toPromise(); + expect(afterResponse).toBe(false); + }); + + it(`Displays an empty list`, async () => { + const t = await createComponent(); + + provideGrammarList([]); + + t.fixture.detectChanges(); + await t.fixture.whenRenderingDone(); + }); + + it(`Displays a list with a single element`, async () => { + const t = await createComponent(); + + const i1 = buildGrammar({ name: "G1" }); + provideGrammarList([i1]); + + t.fixture.detectChanges(); + await t.fixture.whenRenderingDone(); + + const tableElement = t.element.querySelector("table"); + const i1Row = tableElement.querySelector("tbody > tr"); + + expect(i1Row.textContent).toMatch(i1.name); + expect(i1Row.textContent).toMatch(i1.id); + }); + + it(`reloads data on refresh`, async () => { + const t = await createComponent(); + + const i1 = buildGrammar({ name: "B1" }); + provideGrammarList([i1]); + + const initialData = await t.component.grammars.list + .pipe(first()) + .toPromise(); + expect(initialData).toEqual([i1]); + + t.component.onRefresh(); + provideGrammarList([]); + + const refreshedData = await t.component.grammars.list + .pipe(first()) + .toPromise(); + expect(refreshedData).toEqual([]); + }); + + it(`Triggers deletion`, async () => { + const t = await createComponent(); + + const i1 = buildGrammar({ name: "B1" }); + provideGrammarList([i1]); + + t.fixture.detectChanges(); + await t.fixture.whenRenderingDone(); + + const tableElement = t.element.querySelector("table"); + const i1Row = tableElement.querySelector("tbody > tr"); + const i1Delete = i1Row.querySelector( + "button[data-spec=delete]" + ) as HTMLButtonElement; + + i1Delete.click(); + + t.httpTesting + .expectOne({ + method: "DELETE", + url: t.serverApi.individualGrammarUrl(i1.id), + }) + .flush(""); + + provideGrammarList([]); + + const refreshedData = await t.component.grammars.list + .pipe(first()) + .toPromise(); + expect(refreshedData).toEqual([]); + }); +}); diff --git a/client/src/app/admin/grammar/overview-grammar.component.ts b/client/src/app/admin/grammar/overview-grammar.component.ts index eab268400..f94d7fc41 100644 --- a/client/src/app/admin/grammar/overview-grammar.component.ts +++ b/client/src/app/admin/grammar/overview-grammar.component.ts @@ -1,18 +1,41 @@ -import { Component } from "@angular/core"; -import { GrammarDataService } from "../../shared/serverdata"; +import { Component, ViewChild } from "@angular/core"; +import { MatSort } from "@angular/material/sort"; + +import { GrammarListDescription } from "../../shared/syntaxtree"; +import { + ListGrammarDataService, + MutateGrammarService, +} from "../../shared/serverdata"; @Component({ selector: "grammar-overview-selector", templateUrl: "./templates/overview-grammar.html", + providers: [ListGrammarDataService], }) export class OverviewGrammarComponent { - constructor(private _serverData: GrammarDataService) {} + @ViewChild(MatSort, { static: true }) + sort: MatSort; + + constructor( + readonly grammars: ListGrammarDataService, + private _mutate: MutateGrammarService + ) {} - public get availableGrammars() { - return this._serverData.listCache; + async onDeleteGrammar(id: string) { + await this._mutate.deleteSingle(id); } - public deleteGrammar(id: string) { - this._serverData.deleteSingle(id); + /** + * User wants to see a refreshed dataset. + */ + onRefresh() { + this.grammars.listCache.refresh(); } + + displayedColumns: (keyof GrammarListDescription | "actions")[] = [ + "name", + "slug", + "id", + "actions", + ]; } diff --git a/client/src/app/admin/grammar/templates/create-grammar.html b/client/src/app/admin/grammar/templates/create-grammar.html index 5697a7046..791b6a9c4 100644 --- a/client/src/app/admin/grammar/templates/create-grammar.html +++ b/client/src/app/admin/grammar/templates/create-grammar.html @@ -6,6 +6,7 @@

Neue Grammatik

Neue Grammatik
- - - +
@@ -72,6 +64,25 @@

+ +
+ +
+ +
+
+
diff --git a/client/src/app/admin/grammar/templates/meta-code-resource-select.html b/client/src/app/admin/grammar/templates/meta-code-resource-select.html new file mode 100644 index 000000000..3d081819a --- /dev/null +++ b/client/src/app/admin/grammar/templates/meta-code-resource-select.html @@ -0,0 +1,11 @@ + diff --git a/client/src/app/admin/grammar/templates/overview-grammar.html b/client/src/app/admin/grammar/templates/overview-grammar.html index 236893ae9..f7f857a89 100644 --- a/client/src/app/admin/grammar/templates/overview-grammar.html +++ b/client/src/app/admin/grammar/templates/overview-grammar.html @@ -1,33 +1,41 @@ -

- - Grammatiken -

- - - - - - - - - + - - - + + + + + + + + + - -
NameSlugIDAktionen
- {{ grammar.name }} + + + Name + + {{ grammar.name }} + {{ grammar.slug | defaultValue }}{{ grammar.id }} + + + Slug + {{ grammar.slug | defaultValue }} + ID + {{ grammar.id }} + Aktionen
- + + diff --git a/client/src/app/admin/link-grammar.component.spec.ts b/client/src/app/admin/link-grammar.component.spec.ts new file mode 100644 index 000000000..0b2d45746 --- /dev/null +++ b/client/src/app/admin/link-grammar.component.spec.ts @@ -0,0 +1,79 @@ +import { + HttpClientTestingModule, + HttpTestingController, +} from "@angular/common/http/testing"; +import { TestBed } from "@angular/core/testing"; +import { MatSnackBarModule } from "@angular/material/snack-bar"; +import { RouterTestingModule } from "@angular/router/testing"; + +import { ServerApiService } from "../shared"; +import { + ListGrammarDataService, + MutateGrammarService, +} from "../shared/serverdata"; +import { LinkGrammarComponent } from "./link-grammar.component"; +import { generateUUIDv4 } from "../shared/util-browser"; +import { buildGrammar, provideGrammarList } from "../editor/spec-util"; + +describe("LinkGrammarComponent", () => { + async function createComponent(grammarId: string = undefined) { + await TestBed.configureTestingModule({ + imports: [ + HttpClientTestingModule, + MatSnackBarModule, + RouterTestingModule, + ], + providers: [ + ServerApiService, + ListGrammarDataService, + MutateGrammarService, + ], + declarations: [LinkGrammarComponent], + }).compileComponents(); + + let fixture = TestBed.createComponent(LinkGrammarComponent); + let component = fixture.componentInstance; + component.grammarId = grammarId; + + fixture.detectChanges(); + await fixture.whenRenderingDone(); + + const httpTesting = TestBed.inject(HttpTestingController); + const serverApi = TestBed.inject(ServerApiService); + + return { + fixture, + component, + element: fixture.nativeElement as HTMLElement, + httpTesting, + serverApi, + }; + } + + it(`Can be instantiated`, async () => { + const t = await createComponent(); + + expect(t.component).toBeDefined(); + }); + + it(`Renders ID if no other data is available`, async () => { + const id = generateUUIDv4(); + const t = await createComponent(id); + + expect(t.element.innerText.trim()).toEqual(id); + expect(t.element.querySelector("a").href).toContain(id); + }); + + it(`Renders the name once the data is available`, async () => { + const g = buildGrammar({ name: "spec g" }); + const t = await createComponent(g.id); + + provideGrammarList([g]); + + t.fixture.detectChanges(); + await t.fixture.whenRenderingDone(); + + expect(t.element.innerText.trim()).toEqual(g.name); + expect(t.element.querySelector("a").href).toContain(g.id); + }); +}); diff --git a/client/src/app/admin/link-grammar.component.ts b/client/src/app/admin/link-grammar.component.ts index 68d32c417..68d0b2453 100644 --- a/client/src/app/admin/link-grammar.component.ts +++ b/client/src/app/admin/link-grammar.component.ts @@ -2,7 +2,7 @@ import { Component, Input } from "@angular/core"; import { map, filter } from "rxjs/operators"; -import { GrammarDataService } from "../shared/serverdata"; +import { ListGrammarDataService } from "../shared/serverdata"; /** * Creates a link to the grammar with the specified ID. Will attempt to @@ -19,7 +19,7 @@ export class LinkGrammarComponent { */ @Input() grammarId: string; - constructor(private _grammarData: GrammarDataService) {} + constructor(private _grammarData: ListGrammarDataService) {} /** * (Possibly) the description of the grammar diff --git a/client/src/app/admin/project/overview-project.component.spec.ts b/client/src/app/admin/project/overview-project.component.spec.ts new file mode 100644 index 000000000..dd927ee8e --- /dev/null +++ b/client/src/app/admin/project/overview-project.component.spec.ts @@ -0,0 +1,138 @@ +import { FormsModule } from "@angular/forms"; +import { RouterTestingModule } from "@angular/router/testing"; +import { TestBed } from "@angular/core/testing"; +import { NoopAnimationsModule } from "@angular/platform-browser/animations"; +import { + HttpClientTestingModule, + HttpTestingController, +} from "@angular/common/http/testing"; +import { MatSnackBarModule } from "@angular/material/snack-bar"; +import { MatTableModule } from "@angular/material/table"; +import { MatPaginatorModule } from "@angular/material/paginator"; +import { MatSortModule } from "@angular/material/sort"; +import { PortalModule } from "@angular/cdk/portal"; + +import { first } from "rxjs/operators"; + +import { + ServerApiService, + ToolbarService, + LanguageService, +} from "../../shared"; +import { AdminListProjectDataService } from "../../shared/serverdata"; +import { DefaultValuePipe } from "../../shared/default-value.pipe"; +import { PaginatorTableComponent } from "../../shared/table/paginator-table.component"; +import { buildProject, provideProjectList } from "../../editor/spec-util"; + +import { OverviewProjectComponent } from "./overview-project.component"; + +describe("OverviewProjectComponent", () => { + async function createComponent() { + await TestBed.configureTestingModule({ + imports: [ + FormsModule, + NoopAnimationsModule, + MatSnackBarModule, + MatPaginatorModule, + MatSortModule, + MatTableModule, + PortalModule, + HttpClientTestingModule, + RouterTestingModule.withRoutes([]), + ], + providers: [ + ToolbarService, + ServerApiService, + AdminListProjectDataService, + LanguageService, + ], + declarations: [ + OverviewProjectComponent, + DefaultValuePipe, + PaginatorTableComponent, + ], + }).compileComponents(); + + let fixture = TestBed.createComponent(OverviewProjectComponent); + let component = fixture.componentInstance; + fixture.detectChanges(); + + const httpTesting = TestBed.inject(HttpTestingController); + const serverApi = TestBed.inject(ServerApiService); + + return { + fixture, + component, + element: fixture.nativeElement as HTMLElement, + httpTesting, + serverApi, + }; + } + + it(`can be instantiated`, async () => { + const t = await createComponent(); + + expect(t.component).toBeDefined(); + }); + + it(`Displays a loading indicator (or not)`, async () => { + const t = await createComponent(); + + const initialLoading = await t.component.projects.listCache.inProgress + .pipe(first()) + .toPromise(); + expect(initialLoading).toBe(true); + + provideProjectList([]); + + const afterResponse = await t.component.projects.listCache.inProgress + .pipe(first()) + .toPromise(); + expect(afterResponse).toBe(false); + }); + + it(`Displays an empty list`, async () => { + const t = await createComponent(); + + provideProjectList([]); + + t.fixture.detectChanges(); + await t.fixture.whenRenderingDone(); + }); + + it(`Displays a list with a single element`, async () => { + const t = await createComponent(); + + const i1 = buildProject({ name: "G1" }); + provideProjectList([i1]); + + t.fixture.detectChanges(); + await t.fixture.whenRenderingDone(); + + const tableElement = t.element.querySelector("table"); + const i1Row = tableElement.querySelector("tbody > tr"); + + expect(i1Row.textContent).toMatch(i1.name); + expect(i1Row.textContent).toMatch(i1.id); + }); + + it(`reloads data on refresh`, async () => { + const t = await createComponent(); + + const i1 = buildProject({ name: "B1" }); + provideProjectList([i1]); + + const initialData = await t.component.projects.list + .pipe(first()) + .toPromise(); + expect(initialData).toEqual([i1]); + + t.component.onRefresh(); + provideProjectList([]); + + const refreshedData = await t.component.projects.list + .pipe(first()) + .toPromise(); + expect(refreshedData).toEqual([]); + }); +}); diff --git a/client/src/app/admin/project/overview-project.component.ts b/client/src/app/admin/project/overview-project.component.ts index fc8761e7f..fbd4937f9 100644 --- a/client/src/app/admin/project/overview-project.component.ts +++ b/client/src/app/admin/project/overview-project.component.ts @@ -1,55 +1,28 @@ import { Component, ViewChild } from "@angular/core"; -import { MatPaginator } from "@angular/material/paginator"; import { MatSort } from "@angular/material/sort"; -import { Observable } from "rxjs"; - -import { AdminProjectDataService } from "../../shared/serverdata"; +import { AdminListProjectDataService } from "../../shared/serverdata"; import { ProjectListDescription } from "../../shared/project.description"; -import { StringUnion } from "../../shared/string-union"; - -const ProjectListItemKey = StringUnion("id", "slug", "name"); -type ProjectListItemKey = typeof ProjectListItemKey.type; /** * */ @Component({ templateUrl: "./templates/overview-project.html", + providers: [AdminListProjectDataService], }) export class OverviewProjectComponent { - // Angular Material UI to paginate - @ViewChild(MatPaginator) - _paginator: MatPaginator; - // Angular Material UI to sort by different columns - @ViewChild(MatSort) - _sort: MatSort; - - constructor(private _serverData: AdminProjectDataService) {} - - availableProjects: Observable = this._serverData - .list; + @ViewChild(MatSort, { static: true }) + sort: MatSort; - resultsLength = this._serverData.listTotalCount; - - /** - * User has requested a different chunk of data - */ - onChangePagination() { - this._serverData.setListPagination( - this._paginator.pageSize, - this._paginator.pageIndex - ); - } + constructor(readonly projects: AdminListProjectDataService) {} /** - * User has requested different sorting options + * User wants to see a refreshed dataset. */ - onChangeSort() { - if (ProjectListItemKey.guard(this._sort.active)) { - this._serverData.setListOrdering(this._sort.active, this._sort.direction); - } + onRefresh() { + this.projects.listCache.refresh(); } displayedColumns: (keyof ProjectListDescription)[] = ["name", "slug", "id"]; diff --git a/client/src/app/admin/project/templates/overview-project.html b/client/src/app/admin/project/templates/overview-project.html index 851d4284f..7d4822efa 100644 --- a/client/src/app/admin/project/templates/overview-project.html +++ b/client/src/app/admin/project/templates/overview-project.html @@ -1,13 +1,14 @@ - - + @@ -21,16 +22,8 @@ - + - - - -
ID{{ project.id }} + {{ project.id }} + Slug{{ project.slug }} + {{ project.slug }} +
- - + diff --git a/client/src/app/editor/code/block/block-editor.component.ts b/client/src/app/editor/code/block/block-editor.component.ts index 570929975..3db8d5328 100644 --- a/client/src/app/editor/code/block/block-editor.component.ts +++ b/client/src/app/editor/code/block/block-editor.component.ts @@ -6,7 +6,7 @@ import { Observable } from "rxjs"; import { map, switchMap, first, combineLatest } from "rxjs/operators"; import { EditorComponentDescription } from "../../../shared/block/block-language.description"; -import { BlockLanguageDataService } from "../../../shared/serverdata"; +import { IndividualBlockLanguageDataService } from "../../../shared/serverdata"; import { EditorToolbarService } from "../../toolbar.service"; import { CurrentCodeResourceService } from "../../current-coderesource.service"; @@ -47,7 +47,7 @@ export class BlockEditorComponent implements OnInit, OnDestroy { private _route: ActivatedRoute, private _editorComponentsService: EditorComponentsService, private _debugOptions: BlockDebugOptionsService, - private _blockLanguageData: BlockLanguageDataService + private _individualBlockLanguageData: IndividualBlockLanguageDataService ) {} ngOnInit(): void { @@ -142,7 +142,9 @@ export class BlockEditorComponent implements OnInit, OnDestroy { */ readonly editorComponentDescriptions = this.currentResource.pipe( switchMap((c) => c.blockLanguageId), - switchMap((id) => this._blockLanguageData.getLocal(id, "request")), + switchMap((id) => + this._individualBlockLanguageData.getLocal(id, "request") + ), combineLatest( this._debugOptions.showDropDebug.value$, this._debugOptions.showLanguageSelector.value$ diff --git a/client/src/app/editor/code/block/block-host.component.spec.ts b/client/src/app/editor/code/block/block-host.component.spec.ts index 2419d0e6c..03398a503 100644 --- a/client/src/app/editor/code/block/block-host.component.spec.ts +++ b/client/src/app/editor/code/block/block-host.component.spec.ts @@ -17,8 +17,8 @@ import { CodeResource, } from "../../../shared"; import { - BlockLanguageDataService, - GrammarDataService, + IndividualBlockLanguageDataService, + IndividualGrammarDataService, ServerApiService, } from "../../../shared/serverdata"; import { ResourceReferencesOnlineService } from "../../../shared/resource-references-online.service"; @@ -47,9 +47,9 @@ describe("BlockHostComponent", () => { await TestBed.configureTestingModule({ imports: [FormsModule, MatSnackBarModule, HttpClientTestingModule], providers: [ - BlockLanguageDataService, + IndividualBlockLanguageDataService, DragService, - GrammarDataService, + IndividualGrammarDataService, LanguageService, RenderedCodeResourceService, ServerApiService, @@ -186,7 +186,7 @@ describe("BlockHostComponent", () => { expect(c.element.innerText).toEqual("constant"); }); - it(`Single terminal`, async () => { + it(`Single interpolated`, async () => { const treeDesc: NodeDescription = { language: "spec", name: "interpolated", diff --git a/client/src/app/editor/code/block/block-render-error.component.spec.ts b/client/src/app/editor/code/block/block-render-error.component.spec.ts index 93f9ab1d9..9f3b9cd5d 100644 --- a/client/src/app/editor/code/block/block-render-error.component.spec.ts +++ b/client/src/app/editor/code/block/block-render-error.component.spec.ts @@ -5,8 +5,8 @@ import { HttpClientTestingModule } from "@angular/common/http/testing"; import { first } from "rxjs/operators"; import { - BlockLanguageDataService, - GrammarDataService, + IndividualBlockLanguageDataService, + IndividualGrammarDataService, ServerApiService, } from "../../../shared/serverdata"; @@ -40,9 +40,9 @@ describe(`BlockRenderErrorComponent`, () => { await TestBed.configureTestingModule({ imports: [MatSnackBarModule, HttpClientTestingModule], providers: [ - BlockLanguageDataService, + IndividualBlockLanguageDataService, DragService, - GrammarDataService, + IndividualGrammarDataService, LanguageService, ServerApiService, RenderedCodeResourceService, diff --git a/client/src/app/editor/code/block/block-render-input.component.spec.ts b/client/src/app/editor/code/block/block-render-input.component.spec.ts index e928a5b12..136320e99 100644 --- a/client/src/app/editor/code/block/block-render-input.component.spec.ts +++ b/client/src/app/editor/code/block/block-render-input.component.spec.ts @@ -14,8 +14,8 @@ import { CodeResource, } from "../../../shared"; import { - BlockLanguageDataService, - GrammarDataService, + IndividualBlockLanguageDataService, + IndividualGrammarDataService, ServerApiService, } from "../../../shared/serverdata"; @@ -33,8 +33,8 @@ describe("BlockRenderInputComponent", () => { await TestBed.configureTestingModule({ imports: [FormsModule, MatSnackBarModule, HttpClientTestingModule], providers: [ - BlockLanguageDataService, - GrammarDataService, + IndividualBlockLanguageDataService, + IndividualGrammarDataService, LanguageService, RenderedCodeResourceService, ServerApiService, diff --git a/client/src/app/editor/code/block/rendered-coderesource.service.ts b/client/src/app/editor/code/block/rendered-coderesource.service.ts index f087dadd4..898235dd3 100644 --- a/client/src/app/editor/code/block/rendered-coderesource.service.ts +++ b/client/src/app/editor/code/block/rendered-coderesource.service.ts @@ -7,7 +7,6 @@ import { flatMap, map, shareReplay, - tap, } from "rxjs/operators"; import { @@ -21,7 +20,7 @@ import { ResourceReferencesService, RequiredResource, } from "../../../shared/resource-references.service"; -import { GrammarDataService } from "../../../shared/serverdata"; +import { IndividualGrammarDataService } from "../../../shared/serverdata"; /** * This service is provided at the root component that is used to render a coderesource. @@ -59,7 +58,7 @@ export class RenderedCodeResourceService implements OnDestroy { constructor( private _resourceReferences: ResourceReferencesService, - private _grammarData: GrammarDataService + private _grammarData: IndividualGrammarDataService ) { const subValidator = this.validator$.subscribe(this._validator); const subTree = this.syntaxTree$.subscribe(this._syntaxTree); diff --git a/client/src/app/editor/code/create-code-resource.component.spec.ts b/client/src/app/editor/code/create-code-resource.component.spec.ts index 305e0280d..6f1864d5e 100644 --- a/client/src/app/editor/code/create-code-resource.component.spec.ts +++ b/client/src/app/editor/code/create-code-resource.component.spec.ts @@ -20,8 +20,8 @@ import { ResourceReferencesOnlineService } from "../../shared/resource-reference import { ServerApiService, LanguageService } from "../../shared"; import { - BlockLanguageDataService, - GrammarDataService, + IndividualBlockLanguageDataService, + IndividualGrammarDataService, } from "../../shared/serverdata"; import { CodeResourceDescription } from "../../shared/syntaxtree"; import { EmptyComponent } from "../../shared/empty.component"; @@ -52,8 +52,8 @@ describe(`CreateCodeResourceComponent`, () => { SidebarService, ProjectService, CodeResourceService, - BlockLanguageDataService, - GrammarDataService, + IndividualBlockLanguageDataService, + IndividualGrammarDataService, MatSnackBar, Overlay, { @@ -72,7 +72,9 @@ describe(`CreateCodeResourceComponent`, () => { fixture, component, element: fixture.nativeElement as HTMLElement, - projectService: TestBed.get(ProjectService) as ProjectService, + projectService: TestBed.inject(ProjectService), + httpTesting: TestBed.inject(HttpTestingController), + serverApi: TestBed.inject(ServerApiService), }; } @@ -136,19 +138,12 @@ describe(`CreateCodeResourceComponent`, () => { updatedAt: "", }; - const httpTestingController: HttpTestingController = TestBed.get( - HttpTestingController - ); - const serverApi: ServerApiService = TestBed.get(ServerApiService); - // Setup a name and call the creation method t.component.resourceName = r.name; const created = t.component.createCodeResource(); - // Mimic a succesful response - httpTestingController - .expectOne(serverApi.getCodeResourceBaseUrl(p.id)) - .flush(r); + // Mimic a successful response + t.httpTesting.expectOne(t.serverApi.getCodeResourceBaseUrl(p.id)).flush(r); // Ensure the creation has actually happened await created; @@ -157,7 +152,7 @@ describe(`CreateCodeResourceComponent`, () => { expect(p.codeResources).not.toEqual([]); expect(p.codeResources[0].name).toEqual(r.name); - const router: Router = TestBed.get(Router); + const router = TestBed.inject(Router); expect(router.url).toEqual("/" + r.id); }); }); diff --git a/client/src/app/editor/code/create-code-resource.component.ts b/client/src/app/editor/code/create-code-resource.component.ts index f025f12ec..9a5863fbb 100644 --- a/client/src/app/editor/code/create-code-resource.component.ts +++ b/client/src/app/editor/code/create-code-resource.component.ts @@ -55,25 +55,23 @@ export class CreateCodeResourceComponent { /** * Actually creates the CodeResource */ - createCodeResource() { + async createCodeResource() { const p = this._projectService.cachedProject; const b = p.getBlockLanguage(this.blockLanguageId); - const toReturn = this._codeResourceService + const res = await this._codeResourceService .createCodeResource( p, this.resourceName, this.blockLanguageId, b.defaultProgrammingLanguageId ) - .pipe(first()); + .pipe(first()) + .toPromise(); - toReturn.subscribe((res) => { - p.addCodeResource(res); + p.addCodeResource(res); + this._router.navigate([res.id], { relativeTo: this._route.parent }); - this._router.navigate([res.id], { relativeTo: this._route.parent }); - }); - - return toReturn.toPromise(); + return res; } } diff --git a/client/src/app/editor/coderesource.service.ts b/client/src/app/editor/coderesource.service.ts index f320d17cb..5296d67d0 100644 --- a/client/src/app/editor/coderesource.service.ts +++ b/client/src/app/editor/coderesource.service.ts @@ -29,7 +29,7 @@ export class CodeResourceService { blockLanguageId: string, programmingLanguageId: string ) { - const url = this._server.getCodeResourceBaseUrl(project.slug); + const url = this._server.getCodeResourceBaseUrl(project.id); const body = { name: name, diff --git a/client/src/app/editor/current-coderesource.service.ts b/client/src/app/editor/current-coderesource.service.ts index 18173ecbf..c65a0069e 100644 --- a/client/src/app/editor/current-coderesource.service.ts +++ b/client/src/app/editor/current-coderesource.service.ts @@ -11,8 +11,8 @@ import { ValidationResult, } from "../shared/syntaxtree"; import { - BlockLanguageDataService, - GrammarDataService, + IndividualBlockLanguageDataService, + IndividualGrammarDataService, } from "../shared/serverdata"; import { ProjectService } from "./project.service"; @@ -35,8 +35,8 @@ export class CurrentCodeResourceService { constructor( private _projectService: ProjectService, private _resourceReferences: ResourceReferencesService, - private _blockLanguageData: BlockLanguageDataService, - private _grammarData: GrammarDataService + private _individualBlockLanguageData: IndividualBlockLanguageDataService, + private _individualGrammarData: IndividualGrammarDataService ) {} /** @@ -87,8 +87,8 @@ export class CurrentCodeResourceService { readonly blockLanguageGrammar = this.currentResource.pipe( flatMap((r) => r.blockLanguageId), - flatMap((id) => this._blockLanguageData.getLocal(id, "request")), - flatMap((b) => this._grammarData.getLocal(b.grammarId, "request")) + flatMap((id) => this._individualBlockLanguageData.getLocal(id, "request")), + flatMap((b) => this._individualGrammarData.getLocal(b.grammarId, "request")) ); /** diff --git a/client/src/app/editor/image/image-selector.component.ts b/client/src/app/editor/image/image-selector.component.ts index 7d67dfb60..fb4dcd5c6 100644 --- a/client/src/app/editor/image/image-selector.component.ts +++ b/client/src/app/editor/image/image-selector.component.ts @@ -14,9 +14,11 @@ export { AvailableImage }; selector: "image-selector", }) export class ImageSelectorComponent { - @Output() projectImageIdChange = new EventEmitter(); + @Output() + projectImageIdChange = new EventEmitter(); - @Input() public project: Project; + @Input() + public project: Project; private _projectImageId: string; diff --git a/client/src/app/editor/project-exists.guard.ts b/client/src/app/editor/project-exists.guard.ts index b7b9d73f6..50c7b2572 100644 --- a/client/src/app/editor/project-exists.guard.ts +++ b/client/src/app/editor/project-exists.guard.ts @@ -27,12 +27,15 @@ export class ProjectExistsGuard implements CanActivate { private _flashService: FlashService ) {} - canActivate(route: ActivatedRouteSnapshot, _state: RouterStateSnapshot) { - const projectSlug = route.params["projectId"]; - console.log(`ProjectExistsGuard: "${projectSlug}" => ???`); + async canActivate( + route: ActivatedRouteSnapshot, + _state: RouterStateSnapshot + ): Promise { + const projectSlugOrId = route.params["projectId"]; + console.log(`ProjectExistsGuard: "${projectSlugOrId}" => ???`); // Possibly trigger loading the project - this._projectService.setActiveProject(projectSlug, false); + await this._projectService.setActiveProject(projectSlugOrId, false); // And check whether it actually exists const toReturn = this._projectService.activeProject.pipe( @@ -47,7 +50,7 @@ export class ProjectExistsGuard implements CanActivate { } this._flashService.addMessage({ - caption: `Project with slug "${projectSlug}" couldn't be loaded!`, + caption: `Project with slug "${projectSlugOrId}" couldn't be loaded!`, text: message, type: "danger", }); @@ -58,10 +61,10 @@ export class ProjectExistsGuard implements CanActivate { }), map((project) => !!project), tap((res) => - console.log(`ProjectExistsGuard: "${projectSlug}" => ${res}`) + console.log(`ProjectExistsGuard: "${projectSlugOrId}" => ${res}`) ) ); - return toReturn; + return toReturn.toPromise(); } } diff --git a/client/src/app/editor/project-settings/settings.component.ts b/client/src/app/editor/project-settings/settings.component.ts index 2bbbac8c6..673694ca6 100644 --- a/client/src/app/editor/project-settings/settings.component.ts +++ b/client/src/app/editor/project-settings/settings.component.ts @@ -1,7 +1,10 @@ import { Component } from "@angular/core"; import { Router } from "@angular/router"; -import { BlockLanguageDataService } from "../../shared/serverdata"; +import { + IndividualBlockLanguageDataService, + ListBlockLanguageDataService, +} from "../../shared/serverdata"; import { PerformDataService } from "../../shared/authorisation/perform-data.service"; import { ProjectService, Project } from "../project.service"; @@ -30,7 +33,8 @@ export class SettingsComponent { private _toolbarService: EditorToolbarService, private _sidebarService: SidebarService, private _router: Router, - private _serverData: BlockLanguageDataService, + private _individualBlockLanguageData: IndividualBlockLanguageDataService, + private _listBlockLanguageData: ListBlockLanguageDataService, private _performData: PerformDataService ) {} @@ -101,7 +105,7 @@ export class SettingsComponent { * @return All block languages that could currently be used. */ get availableBlockLanguages() { - return this._serverData.list; + return this._listBlockLanguageData.list; } /** @@ -125,6 +129,6 @@ export class SettingsComponent { * Retrieves the name of the given block language */ resolveBlockLanguageName(blockLanguageId: string) { - return this._serverData.getSingle(blockLanguageId); + return this._individualBlockLanguageData.getSingle(blockLanguageId); } } diff --git a/client/src/app/editor/project.service.spec.ts b/client/src/app/editor/project.service.spec.ts index 8a2ecbae7..367546b5c 100644 --- a/client/src/app/editor/project.service.spec.ts +++ b/client/src/app/editor/project.service.spec.ts @@ -12,9 +12,10 @@ import { ResourceReferencesService } from "../shared/resource-references.service import { ResourceReferencesOnlineService } from "../shared/resource-references-online.service"; import { LanguageService, ServerApiService } from "../shared"; import { - BlockLanguageDataService, - GrammarDataService, + IndividualBlockLanguageDataService, + IndividualGrammarDataService, } from "../shared/serverdata"; +import { generateUUIDv4 } from "../shared/util-browser"; import { ProjectService } from "./project.service"; import { specLoadEmptyProject } from "./spec-util"; @@ -27,8 +28,8 @@ describe(`ProjectService`, () => { LanguageService, ServerApiService, ProjectService, - BlockLanguageDataService, - GrammarDataService, + IndividualBlockLanguageDataService, + IndividualGrammarDataService, MatSnackBar, Overlay, { @@ -57,15 +58,34 @@ describe(`ProjectService`, () => { expect(callCount).toEqual(1, "Subscription must have fired once"); }); + it(`Initially loads a project with a specific ID`, async () => { + const projectService = instantiate(); + const id = generateUUIDv4(); + + let callCount = 0; + projectService.activeProject.subscribe((p) => { + expect(p).toBeDefined(); + expect(p.id).toEqual(id); + callCount++; + }); + + const p = await specLoadEmptyProject(projectService, { id }); + + expect(p).toBe(projectService.cachedProject); + expect(projectService.cachedProject.id).toEqual(id); + expect(callCount).toEqual(1, "Subscription must have fired once"); + }); + it(`Doesn't load the same project twice by default`, async () => { const projectService = instantiate(); let callCount = 0; projectService.activeProject.subscribe((_) => callCount++); const p = await specLoadEmptyProject(projectService); - projectService.setActiveProject(p.id, false); + expect(p).withContext("First access").toBe(projectService.cachedProject); - expect(p).toBe(projectService.cachedProject); + projectService.setActiveProject(p.id, false); + expect(p).withContext("Second access").toBe(projectService.cachedProject); expect(callCount).toEqual(1, "Subscription must have fired once"); }); @@ -97,13 +117,15 @@ describe(`ProjectService`, () => { const req = projectService.setActiveProject("0", false); - req.subscribe( - (_) => fail("Request must fail"), - (err: HttpErrorResponse) => expect(err.status).toEqual(404) - ); - httpTestingController .expectOne(serverApi.getProjectUrl("0")) .flush("", { status: 404, statusText: "Not found" }); + + try { + await req; + fail("Request must fail"); + } catch (err) { + expect(err.status).toEqual(404); + } }); }); diff --git a/client/src/app/editor/project.service.ts b/client/src/app/editor/project.service.ts index 9c7af0fb5..87767c50c 100644 --- a/client/src/app/editor/project.service.ts +++ b/client/src/app/editor/project.service.ts @@ -19,6 +19,7 @@ import { ProjectFullDescription, } from "../shared/project"; import { ResourceReferencesService } from "../shared/resource-references.service"; +import { isValidId } from "../shared/util"; export { Project, ProjectFullDescription }; @@ -60,15 +61,15 @@ export class ProjectService { * requiring a new project to be loaded. */ forgetCurrentProject() { + console.log("ProjectService got told to forget current project"); this._subject.next(undefined); - console.log("Project Service: Told to forget current project"); } /** - * @param id The id of the project to set for all subscribers. + * @param slugOrId The id of the project to set for all subscribers. * @param forceRefresh True, if the project should be reloaded if its already known. */ - setActiveProject(id: string, forceRefresh: boolean) { + async setActiveProject(slugOrId: string, forceRefresh: boolean) { // Projects shouldn't change while other requests are in progress if (this._httpRequest) { throw { err: "HTTP request in progress" }; @@ -77,12 +78,15 @@ export class ProjectService { // Clear out the reference to the current project if we are loading // a new project or must reload by sheer force. const currentProject = this._subject.getValue(); - if (forceRefresh || (currentProject && currentProject.slug != id)) { + if ( + forceRefresh || + (currentProject && !currentProject.hasSlugOrId(slugOrId)) + ) { this.forgetCurrentProject(); } // Build the HTTP-request - const url = this._server.getProjectUrl(id); + const url = this._server.getProjectUrl(slugOrId); this._httpRequest = this._http.get(url).pipe( first(), map((res) => new Project(res, this._resourceReferences)), @@ -101,10 +105,6 @@ export class ProjectService { this._httpRequest = undefined; }, (error: Response) => { - if (error instanceof Error) { - console.log(error); - } - // Something has gone wrong, pass the error on to the subscribers // of the project and hope they know what to do about it. console.log( @@ -113,13 +113,13 @@ export class ProjectService { this._subject.error(error); // Reset the internal to be as blank as possible - this._subject = new BehaviorSubject(undefined); + this._subject.next(undefined); this._httpRequest = undefined; } ); // Others may also be interested in the result - return this._httpRequest; + return this._httpRequest.toPromise(); } /** diff --git a/client/src/app/editor/schema.service.ts b/client/src/app/editor/schema.service.ts index 48378bc5f..5aed827c2 100644 --- a/client/src/app/editor/schema.service.ts +++ b/client/src/app/editor/schema.service.ts @@ -2,7 +2,7 @@ import { Injectable } from "@angular/core"; import { HttpClient } from "@angular/common/http"; import { BehaviorSubject, Observable } from "rxjs"; -import { tap, catchError } from "rxjs/operators"; +import { tap, catchError, first } from "rxjs/operators"; import { ServerApiService } from "../shared"; @@ -169,7 +169,7 @@ export class SchemaService { * @param project - the current project * @param table - the table to create inside the database */ - saveNewTable(project: Project, table: Table): Observable { + async saveNewTable(project: Project, table: Table): Promise { const url = this._server.getCreateTableUrl( project.slug, project.currentDatabaseName @@ -181,14 +181,15 @@ export class SchemaService { headers: { "Content-Type": "application/json" }, }) .pipe( - tap((_) => { + first(), + tap(async () => { this.incrementChangeCount(); - this._projectService.setActiveProject(project.slug, true); + await this._projectService.setActiveProject(project.slug, true); this.clearCurrentlyEdited(); }), catchError(this.handleError) ); - return toReturn; + return toReturn.toPromise(); } /** @@ -196,11 +197,11 @@ export class SchemaService { * @param project - the current project * @param table - the table alter */ - sendAlterTableCommands( + async sendAlterTableCommands( project: Project, tableName: string, commandHolder: TableCommandHolder - ): Observable { + ): Promise { const url = this._server.getTableAlterUrl( project.slug, project.currentDatabaseName, @@ -214,14 +215,15 @@ export class SchemaService { headers: { "Content-Type": "application/json" }, }) .pipe( + first(), catchError(this.handleError), - tap((_) => { + tap(async () => { this.incrementChangeCount(); - this._projectService.setActiveProject(project.slug, true); + await this._projectService.setActiveProject(project.slug, true); this.clearCurrentlyEdited(); }) ); - return toReturn; + return toReturn.toPromise(); } /** @@ -229,7 +231,7 @@ export class SchemaService { * @param project - the current project * @param table - the table to delete */ - deleteTable(project: Project, table: Table): Observable { + async deleteTable(project: Project, table: Table): Promise { const url = this._server.getDropTableUrl( project.slug, project.currentDatabaseName, @@ -239,13 +241,14 @@ export class SchemaService { const toReturn = this._http .delete(url, { headers: { "Content-Type": "application/json" } }) .pipe( - tap((_) => { + first(), + tap(async () => { this.incrementChangeCount(); - this._projectService.setActiveProject(project.slug, true); + await this._projectService.setActiveProject(project.slug, true); }), catchError(this.handleError) ); - return toReturn; + return toReturn.toPromise(); } /** diff --git a/client/src/app/editor/schema/schema-table-editor.component.ts b/client/src/app/editor/schema/schema-table-editor.component.ts index 393823315..5fadd569d 100644 --- a/client/src/app/editor/schema/schema-table-editor.component.ts +++ b/client/src/app/editor/schema/schema-table-editor.component.ts @@ -270,7 +270,7 @@ export class SchemaTableEditorComponent implements OnInit, OnDestroy { /** * Function for the save button */ - saveBtn() { + async saveBtn() { console.log("Save!"); // Do we need to create a new table or alter an existing table? if (this.isNewTable) { @@ -285,20 +285,11 @@ export class SchemaTableEditorComponent implements OnInit, OnDestroy { } } console.log(tableToSend); - let schemaref = this._schemaService - .saveNewTable(this._project, tableToSend) - .pipe(first()) - .subscribe( - (_) => { - window.alert("Änderungen gespeichert!"); - this._schemaService.clearCurrentlyEdited(); - this._router.navigate(["../../"], { - relativeTo: this._routeParams, - }); - }, - (error) => this.showError(error) - ); - this._subscriptionRefs.push(schemaref); + await this._schemaService.saveNewTable(this._project, tableToSend); + + window.alert("Änderungen gespeichert!"); + this._schemaService.clearCurrentlyEdited(); + this._router.navigate(["../../"], { relativeTo: this._routeParams }); } else { alert("Tabellenname ist leer!"); } @@ -306,24 +297,15 @@ export class SchemaTableEditorComponent implements OnInit, OnDestroy { // Alter existing table this.dbErrorCode = -1; this.commandsHolder.prepareToSend(); - let schemaref = this._schemaService - .sendAlterTableCommands( - this._project, - this._originalTableName, - this.commandsHolder - ) - .pipe(first()) - .subscribe( - (_) => { - window.alert("Änderungen gespeichert!"); - this._schemaService.clearCurrentlyEdited(); - this._router.navigate(["../../"], { - relativeTo: this._routeParams, - }); - }, - (error) => this.showError(error) - ); - this._subscriptionRefs.push(schemaref); + await this._schemaService.sendAlterTableCommands( + this._project, + this._originalTableName, + this.commandsHolder + ); + + window.alert("Änderungen gespeichert!"); + this._schemaService.clearCurrentlyEdited(); + this._router.navigate(["../../"], { relativeTo: this._routeParams }); } } diff --git a/client/src/app/editor/schema/schema-table.component.ts b/client/src/app/editor/schema/schema-table.component.ts index b70c2fd64..330e773e8 100644 --- a/client/src/app/editor/schema/schema-table.component.ts +++ b/client/src/app/editor/schema/schema-table.component.ts @@ -97,14 +97,12 @@ export class SchemaTableComponent { /** * Function to drop a Table; */ - deleteTable() { - this._schemaService - .deleteTable(this._project, this.table) - .pipe(first()) - .subscribe( - (res) => res, - (error) => this.showError(error) - ); + async deleteTable() { + try { + await this._schemaService.deleteTable(this._project, this.table); + } catch (error) { + this.showError(error); + } } /** diff --git a/client/src/app/editor/spec-util/block-language.data.spec.ts b/client/src/app/editor/spec-util/block-language.data.spec.ts index 9158689b9..f1e546647 100644 --- a/client/src/app/editor/spec-util/block-language.data.spec.ts +++ b/client/src/app/editor/spec-util/block-language.data.spec.ts @@ -1,12 +1,16 @@ import { TestBed } from "@angular/core/testing"; import { HttpTestingController } from "@angular/common/http/testing"; -import { BlockLanguageDescription } from "../../shared/block/block-language.description"; +import { + BlockLanguageDescription, + BlockLanguageListDescription, +} from "../../shared/block/block-language.description"; import { generateUUIDv4 } from "../../shared/util-browser"; import { ServerApiService, - BlockLanguageDataService, + IndividualBlockLanguageDataService, } from "../../shared/serverdata"; +import { provideListResponse, ListOrder } from "./list.data.spec"; const DEFAULT_EMPTY_BLOCKLANGUAGE = Object.freeze({ id: "96659508-e006-4290-926e-0734e7dd061a", @@ -34,7 +38,7 @@ export const ensureLocalBlockLanguageRequest = ( ) => { const httpTestingController = TestBed.inject(HttpTestingController); const serverApi = TestBed.inject(ServerApiService); - const blockData = TestBed.inject(BlockLanguageDataService); + const blockData = TestBed.inject(IndividualBlockLanguageDataService); const toReturn = blockData.getLocal(response.id, "request"); @@ -44,3 +48,25 @@ export const ensureLocalBlockLanguageRequest = ( return toReturn; }; + +export type BlockLanguageOrder = ListOrder; + +/** + * Expects a request for the given list of grammars. If a ordered dataset + * is requested, the `items` param must be already ordered accordingly. + */ +export const provideBlockLanguageList = ( + items: BlockLanguageDescription[], + options?: { + order?: BlockLanguageOrder; + pagination?: { + limit: number; + page: number; + }; + } +) => { + const serverApi = TestBed.inject(ServerApiService); + let reqUrl = serverApi.getBlockLanguageListUrl(); + + return provideListResponse(items, reqUrl, options); +}; diff --git a/client/src/app/editor/spec-util/code-resource.data.spec.ts b/client/src/app/editor/spec-util/code-resource.data.spec.ts new file mode 100644 index 000000000..6edbaec38 --- /dev/null +++ b/client/src/app/editor/spec-util/code-resource.data.spec.ts @@ -0,0 +1,16 @@ +import { MetaCodeResourceListDescription } from "../../admin/grammar/meta-code-resource.description"; +import { generateUUIDv4 } from "../../shared/util-browser"; + +/** + * Generates a valid MetaCodeResourceListDescription. Be aware the + */ +export const buildMetaCodeResourceListItem = ( + override?: Partial +): MetaCodeResourceListDescription => { + return Object.assign( + {}, + { name: "Empty Meta Code Resource" }, + override || {}, + { id: generateUUIDv4() } + ); +}; diff --git a/client/src/app/editor/spec-util/grammar.data.spec.ts b/client/src/app/editor/spec-util/grammar.data.spec.ts index a657cc1a7..8d5f88344 100644 --- a/client/src/app/editor/spec-util/grammar.data.spec.ts +++ b/client/src/app/editor/spec-util/grammar.data.spec.ts @@ -3,8 +3,12 @@ import { HttpTestingController } from "@angular/common/http/testing"; import { GrammarDescription, GrammarListDescription } from "../../shared/"; import { generateUUIDv4 } from "../../shared/util-browser"; -import { ServerApiService, GrammarDataService } from "../../shared/serverdata"; +import { + ServerApiService, + IndividualGrammarDataService, +} from "../../shared/serverdata"; import { JsonApiListResponse } from "../../shared/serverdata/json-api-response"; +import { ListOrder, provideListResponse } from "./list.data.spec"; const DEFAULT_EMPTY_GRAMMAR = Object.freeze({ id: "96659508-e006-4290-926e-0734e7dd061a", @@ -27,9 +31,8 @@ const DEFAULT_EMPTY_GRAMMAR = Object.freeze({ export const buildGrammar = ( override?: Partial ): GrammarDescription => { - return Object.assign({}, DEFAULT_EMPTY_GRAMMAR, override || {}, { - id: generateUUIDv4(), - }); + const id = override?.id ?? generateUUIDv4(); + return Object.assign({}, DEFAULT_EMPTY_GRAMMAR, override || {}, { id }); }; /** @@ -40,9 +43,9 @@ export const ensureLocalGrammarRequest = ( ): Promise => { const httpTestingController = TestBed.inject(HttpTestingController); const serverApi = TestBed.inject(ServerApiService); - const GrammarData = TestBed.inject(GrammarDataService); + const grammarData = TestBed.inject(IndividualGrammarDataService); - const toReturn = GrammarData.getLocal(response.id, "request"); + const toReturn = grammarData.getLocal(response.id, "request"); httpTestingController .expectOne(serverApi.individualGrammarUrl(response.id)) @@ -51,10 +54,7 @@ export const ensureLocalGrammarRequest = ( return toReturn; }; -export interface GrammarOrder { - field: keyof GrammarListDescription; - direction: "asc" | "desc"; -} +export type GrammarOrder = ListOrder; /** * Expects a request for the given list of grammars. If a ordered dataset @@ -70,31 +70,8 @@ export const provideGrammarList = ( }; } ) => { - const httpTestingController = TestBed.inject(HttpTestingController); const serverApi = TestBed.inject(ServerApiService); - - const response: JsonApiListResponse = { - data: items, - meta: { - totalCount: items.length, - }, - }; - let reqUrl = serverApi.getGrammarListUrl(); - if (options) { - reqUrl += "?"; - - const order = options.order; - if (order) { - reqUrl += `orderDirection=${order.direction}&orderField=${order.field}`; - } - - const pagination = options.pagination; - if (pagination) { - const offset = pagination.limit * pagination.page; - reqUrl += `limit=${pagination.limit}&offset=${offset}`; - } - } - httpTestingController.expectOne(reqUrl).flush(response); + return provideListResponse(items, reqUrl, options); }; diff --git a/client/src/app/editor/spec-util/list.data.spec.ts b/client/src/app/editor/spec-util/list.data.spec.ts new file mode 100644 index 000000000..2605c9ae2 --- /dev/null +++ b/client/src/app/editor/spec-util/list.data.spec.ts @@ -0,0 +1,52 @@ +import { TestBed } from "@angular/core/testing"; +import { HttpTestingController } from "@angular/common/http/testing"; + +import { JsonApiListResponse } from "../../shared/serverdata/json-api-response"; + +export interface ListOrder { + field: keyof T; + direction: "asc" | "desc"; +} + +/** + * Expects a request for the given list on the given base URL. If a ordered dataset + * is requested, the `items` param must be already ordered accordingly. + */ +export function provideListResponse( + items: T[], + reqUrl: string, + options?: { + order?: ListOrder; + pagination?: { + limit: number; + page: number; + }; + } +) { + const httpTestingController = TestBed.inject(HttpTestingController); + + const response: JsonApiListResponse = { + data: items, + meta: { + totalCount: items.length, + }, + }; + + if (options) { + // If any options are given, there must be a query part to the URL + reqUrl += "?"; + + const order = options.order; + if (order) { + reqUrl += `orderDirection=${order.direction}&orderField=${order.field}`; + } + + const pagination = options.pagination; + if (pagination) { + const offset = pagination.limit * pagination.page; + reqUrl += `limit=${pagination.limit}&offset=${offset}`; + } + } + + httpTestingController.expectOne(reqUrl).flush(response); +} diff --git a/client/src/app/editor/spec-util/project.data.spec.ts b/client/src/app/editor/spec-util/project.data.spec.ts index 79b4b4ed6..873d87b02 100644 --- a/client/src/app/editor/spec-util/project.data.spec.ts +++ b/client/src/app/editor/spec-util/project.data.spec.ts @@ -3,9 +3,14 @@ import { HttpTestingController } from "@angular/common/http/testing"; import { ProjectService } from "../project.service"; -import { ProjectFullDescription, Project } from "../../shared/project"; +import { + ProjectFullDescription, + Project, + ProjectDescription, +} from "../../shared/project"; import { ServerApiService } from "../../shared"; import { generateUUIDv4 } from "../../shared/util-browser"; +import { ListOrder, provideListResponse } from "./list.data.spec"; const DEFAULT_EMPTY_PROJECT: ProjectFullDescription = { id: "28066939-7d53-40de-a89b-95bf37c982be", @@ -21,6 +26,17 @@ const DEFAULT_EMPTY_PROJECT: ProjectFullDescription = { codeResources: [], }; +/** + * Generates a valid grammar description with a unique ID, that uses + * the given data (if provided) and uses default data + */ +export const buildProject = ( + override?: Partial +): ProjectDescription => { + const id = override?.id ?? generateUUIDv4(); + return Object.assign({}, DEFAULT_EMPTY_PROJECT, override || {}, { id }); +}; + export const specLoadEmptyProject = ( projectService: ProjectService, override?: Partial @@ -30,17 +46,34 @@ export const specLoadEmptyProject = ( ); const serverApi: ServerApiService = TestBed.get(ServerApiService); - const p = Object.assign( - { id: generateUUIDv4() }, - DEFAULT_EMPTY_PROJECT, - override || {} - ); + const id = override?.id ?? generateUUIDv4(); + const p = Object.assign({}, DEFAULT_EMPTY_PROJECT, override || {}, { id }); - const toReturn = projectService - .setActiveProject(DEFAULT_EMPTY_PROJECT.id, true) - .toPromise(); + const toReturn = projectService.setActiveProject(p.id, true); httpTestingController.expectOne(serverApi.getProjectUrl(p.id)).flush(p); return toReturn; }; + +export type ProjectOrder = ListOrder; + +/** + * Expects a request for the given list of grammars. If a ordered dataset + * is requested, the `items` param must be already ordered accordingly. + */ +export const provideProjectList = ( + items: ProjectDescription[], + options?: { + order?: ProjectOrder; + pagination?: { + limit: number; + page: number; + }; + } +) => { + const serverApi = TestBed.inject(ServerApiService); + let reqUrl = serverApi.getAdminProjectListUrl(); + + return provideListResponse(items, reqUrl, options); +}; diff --git a/client/src/app/editor/spec-util/user.data.spec.ts b/client/src/app/editor/spec-util/user.data.spec.ts index ba7728547..4f554828d 100644 --- a/client/src/app/editor/spec-util/user.data.spec.ts +++ b/client/src/app/editor/spec-util/user.data.spec.ts @@ -15,10 +15,8 @@ const DEFAULT_EMPTY_USER: UserDescription = { export const specSignInUser = ( override?: Partial ): UserDescription => { - const httpTestingController: HttpTestingController = TestBed.get( - HttpTestingController - ); - const serverApi: ServerApiService = TestBed.get(ServerApiService); + const httpTestingController = TestBed.inject(HttpTestingController); + const serverApi = TestBed.inject(ServerApiService); const p = Object.assign( { userId: generateUUIDv4() }, diff --git a/client/src/app/front/create-project.component.spec.ts b/client/src/app/front/create-project.component.spec.ts index 8fd7963d5..ae86cfec6 100644 --- a/client/src/app/front/create-project.component.spec.ts +++ b/client/src/app/front/create-project.component.spec.ts @@ -56,10 +56,8 @@ describe("CreateProjectComponent", () => { it(`Creates a project with valid name and slug`, async () => { const c = await createComponent(); - const httpTestingController: HttpTestingController = TestBed.get( - HttpTestingController - ); - const serverApi: ServerApiService = TestBed.get(ServerApiService); + const httpTestingController = TestBed.inject(HttpTestingController); + const serverApi = TestBed.inject(ServerApiService); c.component.params.name = "Name des Projekts"; c.component.params.slug = "name-des-projekts"; diff --git a/client/src/app/shared/block/block-language.description.ts b/client/src/app/shared/block/block-language.description.ts index e4057bbc1..93e46dd62 100644 --- a/client/src/app/shared/block/block-language.description.ts +++ b/client/src/app/shared/block/block-language.description.ts @@ -4,6 +4,8 @@ import { } from "./block.description"; import { BlockLanguageGeneratorDocument } from "./generator/generator.description"; +import { JsonApiListResponse } from "../serverdata/json-api-response"; + /** * Augments a language with information about the UI layer. This definition is * used by the editors, especially the block editor, to show customized editors @@ -193,11 +195,15 @@ export interface BlockLanguageDocument { /** * The server hands out additional information that is only used for display purposes. */ -export interface BlockLanguageListResponseDescription +export interface BlockLanguageListItemDescription extends BlockLanguageListDescription { generated: boolean; } +export type BlockLanguageListResponseDescription = JsonApiListResponse< + BlockLanguageListItemDescription +>; + export function isBlockLanguageDescription( obj: any ): obj is BlockLanguageDescription { diff --git a/client/src/app/shared/project.ts b/client/src/app/shared/project.ts index 2529a3e38..a5c8f239b 100644 --- a/client/src/app/shared/project.ts +++ b/client/src/app/shared/project.ts @@ -14,6 +14,7 @@ import { CodeResource, GrammarDescription } from "./syntaxtree"; import { BlockLanguage } from "../shared/block"; import { DatabaseSchemaAdditionalContext } from "./syntaxtree/sql/sql.validator"; import { ResourceReferencesService } from "./resource-references.service"; +import { isValidId } from "./util"; export { ProjectDescription, ProjectFullDescription }; @@ -127,6 +128,14 @@ export class Project implements Saveable { return this._id; } + hasSlugOrId(slugOrId: string) { + if (isValidId(slugOrId)) { + return this.id == slugOrId; + } else { + return this.slug == slugOrId; + } + } + /** * @return Project wide data that may or may not be relevant during validation. */ diff --git a/client/src/app/shared/resource-references-online.service.ts b/client/src/app/shared/resource-references-online.service.ts index d70abf690..d6265012e 100644 --- a/client/src/app/shared/resource-references-online.service.ts +++ b/client/src/app/shared/resource-references-online.service.ts @@ -5,7 +5,10 @@ import { RequiredResource, } from "./resource-references.service"; import { LanguageService } from "./language.service"; -import { BlockLanguageDataService, GrammarDataService } from "./serverdata"; +import { + IndividualBlockLanguageDataService, + IndividualGrammarDataService, +} from "./serverdata"; import { BlockLanguage } from "./block"; /** @@ -18,15 +21,15 @@ export class ResourceReferencesOnlineService extends ResourceReferencesService { constructor( private _languageService: LanguageService, - private _blockLanguageData: BlockLanguageDataService, - private _grammarLanguageData: GrammarDataService + private _individualBlockLanguageData: IndividualBlockLanguageDataService, + private _individualGrammarData: IndividualGrammarDataService ) { super(); } getBlockLanguage(id: string, onMissing: "undefined" | "throw") { if (!this._blockLanguages[id]) { - const desc = this._blockLanguageData.getLocal(id, "undefined"); + const desc = this._individualBlockLanguageData.getLocal(id, "undefined"); if (!desc) { if (onMissing === "throw") { throw new Error( @@ -45,7 +48,7 @@ export class ResourceReferencesOnlineService extends ResourceReferencesService { } getGrammarDescription(id: string, onMissing: "undefined" | "throw") { - const g = this._grammarLanguageData.getLocal(id, "undefined"); + const g = this._individualGrammarData.getLocal(id, "undefined"); if (!g && onMissing === "throw") { throw new Error(`Could not retriebe grammar "${id}" on the fly`); } else { @@ -62,9 +65,9 @@ export class ResourceReferencesOnlineService extends ResourceReferencesService { const requests: Promise[] = req.map((r) => { switch (r.type) { case "blockLanguage": - return this._blockLanguageData.getLocal(r.id, "request"); + return this._individualBlockLanguageData.getLocal(r.id, "request"); case "grammar": - return this._grammarLanguageData.getLocal(r.id, "request"); + return this._individualGrammarData.getLocal(r.id, "request"); case "blockLanguageGrammar": return this.ensureBlockLanguageGrammar(r.id); } diff --git a/client/src/app/shared/serverdata/admin-project-data.service.ts b/client/src/app/shared/serverdata/admin-project-data.service.ts index 267788b6b..3f933926a 100644 --- a/client/src/app/shared/serverdata/admin-project-data.service.ts +++ b/client/src/app/shared/serverdata/admin-project-data.service.ts @@ -1,32 +1,19 @@ import { Injectable } from "@angular/core"; import { HttpClient } from "@angular/common/http"; -import { MatSnackBar } from "@angular/material/snack-bar"; -import { - ProjectDescription, - ProjectListDescription, -} from "../project.description"; +import { ProjectListDescription } from "../project.description"; import { ServerApiService } from "./serverapi.service"; -import { DataService } from "./data-service"; +import { ListData } from "./list-data"; /** * Convenient and cached access to server side project descriptions. */ @Injectable() -export class AdminProjectDataService extends DataService< - ProjectListDescription, - ProjectDescription +export class AdminListProjectDataService extends ListData< + ProjectListDescription > { - public constructor( - private _serverApi: ServerApiService, - snackBar: MatSnackBar, - http: HttpClient - ) { - super(http, snackBar, _serverApi.getAdminProjectListUrl(), "Project"); - } - - protected resolveIndividualUrl(id: string): string { - return this._serverApi.getProjectUrl(id); + public constructor(serverApi: ServerApiService, http: HttpClient) { + super(http, serverApi.getAdminProjectListUrl()); } } diff --git a/client/src/app/shared/serverdata/blocklanguage-data.service.ts b/client/src/app/shared/serverdata/blocklanguage-data.service.ts index 05116eb46..ee67db160 100644 --- a/client/src/app/shared/serverdata/blocklanguage-data.service.ts +++ b/client/src/app/shared/serverdata/blocklanguage-data.service.ts @@ -1,51 +1,67 @@ -import { Injectable } from "@angular/core"; +import { Injectable, OnDestroy } from "@angular/core"; import { HttpClient } from "@angular/common/http"; import { MatSnackBar } from "@angular/material/snack-bar"; +import { Subscription } from "rxjs"; + import { BlockLanguageListDescription, BlockLanguageDescription, } from "../block/block-language.description"; import { ServerApiService } from "./serverapi.service"; -import { DataService } from "./data-service"; +import { ListData } from "./list-data"; +import { IndividualData } from "./individual-data"; +import { MutateData } from "./mutate-data"; + +const urlResolver = (serverApi: ServerApiService) => { + return (id: string) => serverApi.individualBlockLanguageUrl(id); +}; -/** - * Convenient and cached access to server side grammar descriptions. - */ @Injectable() -export class BlockLanguageDataService extends DataService< - BlockLanguageListDescription, +export class IndividualBlockLanguageDataService extends IndividualData< + BlockLanguageDescription +> { + constructor(serverApi: ServerApiService, http: HttpClient) { + super(http, urlResolver(serverApi), "BlockLanguage"); + } +} + +@Injectable() +export class MutateBlockLanguageService extends MutateData< BlockLanguageDescription > { public constructor( - private _serverApi: ServerApiService, + http: HttpClient, snackBar: MatSnackBar, - http: HttpClient + serverApi: ServerApiService ) { - super( - http, - snackBar, - _serverApi.getBlockLanguageListUrl(), - "BlockLanguage" - ); + super(http, snackBar, urlResolver(serverApi), "BlockLanguage"); } +} - protected resolveIndividualUrl(id: string): string { - return this._serverApi.individualBlockLanguageUrl(id); - } +@Injectable() +export class ListBlockLanguageDataService + extends ListData + implements OnDestroy { + private _subscriptions: Subscription[] = []; + + public constructor( + serverApi: ServerApiService, + http: HttpClient, + mutateService: MutateBlockLanguageService + ) { + super(http, serverApi.getBlockLanguageListUrl()); + + const s = mutateService.listInvalidated.subscribe(() => + this.listCache.refresh() + ); - /** - * Deletes the block language with the given ID. - */ - deleteBlockLanguage(id: string) { - this.deleteSingle(id); + this._subscriptions = [s]; } - /** - * Updates the given block language - */ - updateBlockLanguage(desc: BlockLanguageDescription) { - this.updateSingle(desc); + ngOnDestroy() { + this._subscriptions.forEach((s) => s.unsubscribe()); + this._subscriptions = []; } } diff --git a/client/src/app/shared/serverdata/data-service.ts b/client/src/app/shared/serverdata/data-service.ts deleted file mode 100644 index c73c7bbd5..000000000 --- a/client/src/app/shared/serverdata/data-service.ts +++ /dev/null @@ -1,272 +0,0 @@ -import { HttpClient, HttpParams } from "@angular/common/http"; -import { MatSnackBar } from "@angular/material/snack-bar"; - -import { Observable, BehaviorSubject } from "rxjs"; -import { first, map, tap } from "rxjs/operators"; - -import { IdentifiableResourceDescription } from "../resource.description"; - -import { - JsonApiListResponse, - isJsonApiListResponse, -} from "./json-api-response"; -import { CachedRequest, IndividualDescriptionCache } from "./request-cache"; -import { objectOmit } from "../util"; - -/** - * Basic building block to access "typically" structured data from the server. - * May additionally also provide a local cache for objects that need to be - * persisted across multiple components. - */ -export abstract class DataService< - TList extends IdentifiableResourceDescription, - TSingle extends IdentifiableResourceDescription -> { - // These parameters are passed to every listing request - private _listGetParams = new HttpParams(); - - private _listTotalCount = new BehaviorSubject(undefined); - - // Backing field for local cache, (obviously) not persisted between browser - // sessions - private _localCache: { [id: string]: TSingle } = {}; - - public constructor( - // Deriving classes may need to make HTTP requests of their own - protected _http: HttpClient, - private _snackBar: MatSnackBar, - private _listUrl: string, - private _speakingName: string - ) {} - - /** - * The cache of all descriptions that are available to the current user. - */ - readonly listCache = new CachedRequest(this.createListRequest()); - - /** - * The individually cached resources. - */ - protected readonly _individualCache = new IndividualDescriptionCache( - this._http, - (id) => this.resolveIndividualUrl(id) - ); - - /** - * An observable of all descriptions that are available to the current user. - */ - readonly list = this.listCache.value; - - /** - * @return The total number of list items available. - */ - get peekListTotalCount() { - return this._listTotalCount.value; - } - - readonly listTotalCount = this._listTotalCount.asObservable(); - - /** - * Calculates the URL that can be used to retrieve the resource in question. - * - * @param id The ID of the resource to retrieve. - * @return The URL that can be used to retrieve the resource in question. - */ - protected abstract resolveIndividualUrl(id: string): string; - - private createListRequest() { - return this._http - .get>(this._listUrl, { - params: this._listGetParams, - }) - .pipe( - map((response) => { - if (isJsonApiListResponse(response)) { - this._listTotalCount.next(response.meta.totalCount); - return response.data; - } else { - this._listTotalCount.next(undefined); - return response; - } - }) - ); - } - - /** - * Change the parameters that are passed to the HTTP GET requests for - * lists of data. This is useful for pagination and sorting. - */ - private changeListParameters(newParams: HttpParams) { - this._listGetParams = newParams; - this.listCache.refresh(this.createListRequest()); - } - - /** - * Set the ordering parameters that should be used for all subsequent - * listing requests. - */ - setListOrdering(columnName: keyof TList, order: "asc" | "desc" | "") { - if (order === "") { - this._listGetParams = this._listGetParams - .delete("orderDirection") - .delete("orderField"); - } else { - this._listGetParams = this._listGetParams - .set("orderDirection", order) - .set("orderField", columnName.toString()); - } - - this.changeListParameters(this._listGetParams); - } - - /** - * Set the limits that should be used for all subsequent listing requests. - */ - setListPagination(pageSize: number, currentPage: number) { - this._listGetParams = this._listGetParams.set("limit", pageSize.toString()); - this._listGetParams = this._listGetParams.set( - "offset", - (pageSize * currentPage).toString() - ); - - this.changeListParameters(this._listGetParams); - } - - /** - * @param id The id of the searched resource - * @param refresh True, if the cache must be updated - * - * @return The details of the specified resource. - */ - getSingle(id: string, refresh = false): Observable { - if (refresh) { - this._individualCache.refreshDescription(id); - } - - return this._individualCache.getDescription(id); - } - - /** - * Updates an individual resource on the server. Uses the same - * URL as the individual data access, but with HTTP PUT. - */ - updateSingle(desc: TSingle, showErrorFeedback = true): Promise { - const toReturn = new Promise((resolve, reject) => { - const descWithoutId = objectOmit("id", desc); - - this._http - .put(this.resolveIndividualUrl(desc.id), descWithoutId) - .pipe(first()) - .subscribe( - (updatedDesc) => { - console.log(`Updated ${this._speakingName} with ID "${desc.id}"`); - this._snackBar.open( - `Updated ${this._speakingName} with ID "${desc.id}"`, - "", - { duration: 3000 } - ); - this.listCache.refresh(); - resolve(updatedDesc); - }, - (err) => { - console.warn( - `Update failed: ${this._speakingName} with ID "${desc.id}"` - ); - - if (showErrorFeedback) { - this._snackBar.open( - `Could not update ${this._speakingName} with ID "${desc.id}"`, - "OK 😞" - ); - } else { - reject(err); - } - } - ); - }); - - return toReturn; - } - - /** - * Deletes a individual server on the server. Uses the same - * URL as the individual data access, but with HTTP DELETE. - * - * @param id The ID of the resouce. - */ - deleteSingle(id: string, showErrorFeedback = true): Promise { - const toReturn = new Promise((resolve, reject) => { - this._http - .delete(this.resolveIndividualUrl(id)) - .pipe(first()) - .subscribe( - (_) => { - console.log(`Deleted ${this._speakingName} with "${id}"`); - this._snackBar.open( - `Deleted ${this._speakingName} with ID "${id}"`, - "", - { duration: 3000 } - ); - this.listCache.refresh(); - - resolve(); - }, - (err) => { - console.warn( - `Delete failed: ${this._speakingName} with ID "${id}"` - ); - if (showErrorFeedback) { - this._snackBar.open( - `Could not delete ${this._speakingName} with ID "${id}"`, - "OK 😞" - ); - } else { - reject(err); - } - } - ); - }); - - return toReturn; - } - - /** - * @param id The ID of the item to retrieve from cache - * @param onMissing What to do if the item does not exist: Issue a request or return `undefined` - * @return A locally cached version of the given resource - */ - getLocal(id: string, onMissing: "undefined"): TSingle; - getLocal(id: string, onMissing: "request"): Promise; - getLocal( - id: string, - onMissing: "request" | "undefined" = "undefined" - ): TSingle | Promise { - let toReturn = this._localCache[id]; - if (!toReturn && onMissing === "request") { - return this.getSingle(id) - .pipe( - // Without taking only the first item from `getSingle`, the promise - // will never be fulfilled - first(), - // Store value as a side effect - tap((value) => this.setLocal(value)) - ) - .toPromise(); - } else if (onMissing === "request") { - return Promise.resolve(toReturn); - } else { - return toReturn; - } - } - - /** - * @param res The resource to cache locally - */ - setLocal(res: TSingle) { - console.log( - `Cache "${this._speakingName}" - Item with id ${res.id} added: `, - res - ); - this._localCache[res.id] = res; - } -} diff --git a/client/src/app/shared/serverdata/grammar-data.service.spec.ts b/client/src/app/shared/serverdata/grammar-data.service.spec.ts index c1d331289..0ad2255f5 100644 --- a/client/src/app/shared/serverdata/grammar-data.service.spec.ts +++ b/client/src/app/shared/serverdata/grammar-data.service.spec.ts @@ -1,12 +1,9 @@ -import { HttpErrorResponse } from "@angular/common/http"; import { TestBed } from "@angular/core/testing"; import { HttpClientTestingModule, HttpTestingController, } from "@angular/common/http/testing"; - -import { MatSnackBar } from "@angular/material/snack-bar"; -import { Overlay } from "@angular/cdk/overlay"; +import { MatSnackBarModule } from "@angular/material/snack-bar"; import { first } from "rxjs/operators"; @@ -19,18 +16,21 @@ import { import { ResourceReferencesService } from "../resource-references.service"; import { ResourceReferencesOnlineService } from "../resource-references-online.service"; -import { GrammarDataService } from "./grammar-data.service"; +import { + ListGrammarDataService, + MutateGrammarService, +} from "./grammar-data.service"; import { ServerApiService } from "./serverapi.service"; +import { NoopAnimationsModule } from "@angular/platform-browser/animations"; -describe(`GrammarDataService`, () => { +describe(`ListGrammarDataService`, () => { function instantiate() { TestBed.configureTestingModule({ - imports: [HttpClientTestingModule], + imports: [HttpClientTestingModule, MatSnackBarModule], providers: [ ServerApiService, - GrammarDataService, - MatSnackBar, - Overlay, + ListGrammarDataService, + MutateGrammarService, { provide: ResourceReferencesService, useClass: ResourceReferencesOnlineService, @@ -40,7 +40,7 @@ describe(`GrammarDataService`, () => { }); return { - service: TestBed.inject(GrammarDataService), + service: TestBed.inject(ListGrammarDataService), }; } @@ -57,13 +57,15 @@ describe(`GrammarDataService`, () => { provideGrammarList([]); const list = await pending; - const totalCount = await fixture.service.listTotalCount + const totalCount = await fixture.service.listTotalCount$ .pipe(first()) .toPromise(); - expect(list).toEqual([]); - expect(totalCount).toEqual(0); - expect(fixture.service.peekListTotalCount).toEqual(0); + expect(list).withContext("Direct list comparision").toEqual([]); + expect(totalCount).withContext("listTotalCount$").toEqual(0); + expect(fixture.service.peekListTotalCount) + .withContext("peekListTotalCount") + .toEqual(0); }); it(`Listing dataset with two items (default order)`, async () => { @@ -75,13 +77,49 @@ describe(`GrammarDataService`, () => { provideGrammarList(expectedList); const list = await pending; - const totalCount = await fixture.service.listTotalCount + const totalCount = await fixture.service.listTotalCount$ + .pipe(first()) + .toPromise(); + + expect(list).withContext("Direct list comparision").toEqual(expectedList); + expect(totalCount) + .withContext("listTotalCount$") + .toEqual(expectedList.length); + expect(fixture.service.peekListTotalCount) + .withContext("peekListTotalCount") + .toEqual(expectedList.length); + }); + + it(`Listing dataset with two items (name descending)`, async () => { + const fixture = instantiate(); + + const expectedList = [ + buildGrammar({ name: "B" }), + buildGrammar({ name: "A" }), + ]; + + const order: GrammarOrder = { + direction: "desc", + field: "name", + }; + + fixture.service.setListOrdering(order.field, order.direction); + + const pending = fixture.service.list.pipe(first()).toPromise(); + provideGrammarList(expectedList, { order }); + + const list = await pending; + const totalCount = await fixture.service.listTotalCount$ .pipe(first()) .toPromise(); - expect(list).toEqual(expectedList); - expect(totalCount).toEqual(expectedList.length); - expect(fixture.service.peekListTotalCount).toEqual(expectedList.length); + expect(list).withContext("Direct list comparision").toEqual(expectedList); + expect(totalCount) + .withContext("listTotalCount$") + .toEqual(expectedList.length); + expect(fixture.service.peekListTotalCount) + .withContext("peekListTotalCount") + .toEqual(expectedList.length); }); it(`Listing dataset with two items (name ascending)`, async () => { @@ -103,13 +141,17 @@ describe(`GrammarDataService`, () => { provideGrammarList(expectedList, { order }); const list = await pending; - const totalCount = await fixture.service.listTotalCount + const totalCount = await fixture.service.listTotalCount$ .pipe(first()) .toPromise(); - expect(list).toEqual(expectedList); - expect(totalCount).toEqual(expectedList.length); - expect(fixture.service.peekListTotalCount).toEqual(expectedList.length); + expect(list).withContext("Direct list comparision").toEqual(expectedList); + expect(totalCount) + .withContext("listTotalCount$") + .toEqual(expectedList.length); + expect(fixture.service.peekListTotalCount) + .withContext("peekListTotalCount") + .toEqual(expectedList.length); }); it(`Listing dataset with two items (slug descending)`, async () => { @@ -131,13 +173,17 @@ describe(`GrammarDataService`, () => { provideGrammarList(expectedList, { order }); const list = await pending; - const totalCount = await fixture.service.listTotalCount + const totalCount = await fixture.service.listTotalCount$ .pipe(first()) .toPromise(); - expect(list).toEqual(expectedList); - expect(totalCount).toEqual(expectedList.length); - expect(fixture.service.peekListTotalCount).toEqual(expectedList.length); + expect(list).withContext("Direct list comparision").toEqual(expectedList); + expect(totalCount) + .withContext("listTotalCount$") + .toEqual(expectedList.length); + expect(fixture.service.peekListTotalCount) + .withContext("peekListTotalCount") + .toEqual(expectedList.length); }); it(`Listing dataset while resetting the list order`, async () => { @@ -153,13 +199,15 @@ describe(`GrammarDataService`, () => { provideGrammarList(expectedList); const list = await pending; - const totalCount = await fixture.service.listTotalCount + const totalCount = await fixture.service.listTotalCount$ .pipe(first()) .toPromise(); - expect(list).toEqual([]); - expect(totalCount).toEqual(0); - expect(fixture.service.peekListTotalCount).toEqual(0); + expect(list).withContext("Direct list comparision").toEqual([]); + expect(totalCount).withContext("listTotalCount$").toEqual(0); + expect(fixture.service.peekListTotalCount) + .withContext("peekListTotalCount") + .toEqual(0); }); it(`Listing dataset with pagination`, async () => { @@ -176,12 +224,137 @@ describe(`GrammarDataService`, () => { provideGrammarList(expectedList, { pagination }); const list = await pending; - const totalCount = await fixture.service.listTotalCount + const totalCount = await fixture.service.listTotalCount$ .pipe(first()) .toPromise(); - expect(list).toEqual([]); - expect(totalCount).toEqual(0); - expect(fixture.service.peekListTotalCount).toEqual(0); + expect(list).withContext("Direct list comparision").toEqual([]); + expect(totalCount).withContext("listTotalCount$").toEqual(0); + expect(fixture.service.peekListTotalCount) + .withContext("peekListTotalCount") + .toEqual(0); + }); +}); + +describe(`MutateGrammarDataService`, () => { + function instantiate() { + TestBed.configureTestingModule({ + imports: [ + HttpClientTestingModule, + MatSnackBarModule, + NoopAnimationsModule, + ], + providers: [ + ServerApiService, + ListGrammarDataService, + MutateGrammarService, + { + provide: ResourceReferencesService, + useClass: ResourceReferencesOnlineService, + }, + ], + declarations: [], + }); + + return { + list: TestBed.inject(ListGrammarDataService), + mutate: TestBed.inject(MutateGrammarService), + httpTesting: TestBed.inject(HttpTestingController), + serverApi: TestBed.inject(ServerApiService), + }; + } + + it(`can be instantiated`, async () => { + const fixture = instantiate(); + + expect(fixture.mutate).toBeDefined(); + }); + + it(`makes a successful DELETE request`, async () => { + const fixture = instantiate(); + + let listInvalidatedCalls = 0; + fixture.mutate.listInvalidated.subscribe((_) => ++listInvalidatedCalls); + + const done = fixture.mutate.deleteSingle("grammarId"); + fixture.httpTesting + .expectOne({ + url: fixture.serverApi.individualGrammarUrl("grammarId"), + method: "DELETE", + }) + .flush(""); + + await done; + + expect(listInvalidatedCalls).toEqual(1); + }); + + it(`makes a unsuccessful DELETE request`, async () => { + const fixture = instantiate(); + + let listInvalidatedCalls = 0; + fixture.mutate.listInvalidated.subscribe((_) => ++listInvalidatedCalls); + + const done = fixture.mutate.deleteSingle("grammarId", false); + fixture.httpTesting + .expectOne({ + url: fixture.serverApi.individualGrammarUrl("grammarId"), + method: "DELETE", + }) + .flush("", { status: 400, statusText: "Nope" }); + + try { + await done; + fail("Must throw"); + } catch {} + + expect(listInvalidatedCalls).toEqual(0); + }); + + it(`makes a POST request`, async () => { + const fixture = instantiate(); + + let listInvalidatedCalls = 0; + fixture.mutate.listInvalidated.subscribe((_) => ++listInvalidatedCalls); + + const originalDescription = { id: "grammarId" } as any; + + const done = fixture.mutate.updateSingle(originalDescription); + fixture.httpTesting + .expectOne({ + url: fixture.serverApi.individualGrammarUrl("grammarId"), + method: "PUT", + }) + .flush(""); + + await done; + + expect(listInvalidatedCalls).toEqual(1); + expect(originalDescription) + .withContext("Original description must not be mutated") + .toEqual({ id: "grammarId" }); + }); + + it(`makes a unsuccessful POST request`, async () => { + const fixture = instantiate(); + + let listInvalidatedCalls = 0; + fixture.mutate.listInvalidated.subscribe((_) => ++listInvalidatedCalls); + + const originalDescription = { id: "grammarId" } as any; + const done = fixture.mutate.updateSingle(originalDescription, false); + fixture.httpTesting + .expectOne({ + url: fixture.serverApi.individualGrammarUrl("grammarId"), + method: "PUT", + }) + .flush("", { status: 400, statusText: "Nope" }); + + try { + await done; + fail("Must throw"); + } catch {} + + expect(listInvalidatedCalls).toEqual(0); }); }); diff --git a/client/src/app/shared/serverdata/grammar-data.service.ts b/client/src/app/shared/serverdata/grammar-data.service.ts index 8c23a9125..579332c61 100644 --- a/client/src/app/shared/serverdata/grammar-data.service.ts +++ b/client/src/app/shared/serverdata/grammar-data.service.ts @@ -1,41 +1,66 @@ -import { Injectable } from "@angular/core"; +import { Injectable, OnDestroy, Optional } from "@angular/core"; import { HttpClient } from "@angular/common/http"; import { MatSnackBar } from "@angular/material/snack-bar"; +import { Subscription } from "rxjs"; + import { GrammarDescription, GrammarListDescription } from "../syntaxtree"; -import { fieldCompare } from "../util"; import { ServerApiService } from "./serverapi.service"; -import { DataService } from "./data-service"; +import { ListData } from "./list-data"; +import { IndividualData } from "./individual-data"; +import { MutateData } from "./mutate-data"; -import { map } from "rxjs/operators"; +const urlResolver = (serverApi: ServerApiService) => { + return (id: string) => serverApi.individualGrammarUrl(id); +}; /** - * Convenient and cached access to server side grammar descriptions. + * Cached access to individual grammars */ @Injectable() -export class GrammarDataService extends DataService< - GrammarListDescription, +export class IndividualGrammarDataService extends IndividualData< GrammarDescription > { + constructor(serverApi: ServerApiService, http: HttpClient) { + super(http, urlResolver(serverApi), "Grammar"); + } +} + +@Injectable() +export class MutateGrammarService extends MutateData { public constructor( - private _serverApi: ServerApiService, + http: HttpClient, snackBar: MatSnackBar, - http: HttpClient + serverApi: ServerApiService ) { - super(http, snackBar, _serverApi.getGrammarListUrl(), "Grammar"); + super(http, snackBar, urlResolver(serverApi), "Grammar"); } +} - protected resolveIndividualUrl(id: string): string { - return this._serverApi.individualGrammarUrl(id); +/** + * Cached access to lists of grammars. + */ +@Injectable() +export class ListGrammarDataService extends ListData + implements OnDestroy { + private _subscriptions: Subscription[] = []; + + constructor( + serverApi: ServerApiService, + http: HttpClient, + mutateService: MutateGrammarService + ) { + super(http, serverApi.getGrammarListUrl()); + + const s = mutateService.listInvalidated.subscribe(() => + this.listCache.refresh() + ); + this._subscriptions = [s]; } - /** - * Grammars in stable sort order. - * - * @return All grammars that are known on the server and available for the current user. - */ - readonly list = this.listCache.value.pipe( - map((list) => list.sort(fieldCompare("name"))) - ); + ngOnDestroy() { + this._subscriptions.forEach((s) => s.unsubscribe()); + this._subscriptions = []; + } } diff --git a/client/src/app/shared/serverdata/index.ts b/client/src/app/shared/serverdata/index.ts index 285813aa8..5096bb16d 100644 --- a/client/src/app/shared/serverdata/index.ts +++ b/client/src/app/shared/serverdata/index.ts @@ -5,3 +5,6 @@ export * from "./grammar-data.service"; export * from "./server-data.service"; export * from "./project-data.service"; export * from "./serverapi.service"; +export * from "./individual-data"; +export * from "./list-data"; +export * from "./mutate-data"; diff --git a/client/src/app/shared/serverdata/individual-data.ts b/client/src/app/shared/serverdata/individual-data.ts new file mode 100644 index 000000000..a0336c257 --- /dev/null +++ b/client/src/app/shared/serverdata/individual-data.ts @@ -0,0 +1,87 @@ +import { HttpClient } from "@angular/common/http"; + +import { Observable } from "rxjs"; +import { first, tap } from "rxjs/operators"; + +import { IdentifiableResourceDescription } from "../resource.description"; + +import { IndividualDescriptionCache } from "./request-cache"; +import { ResolveIndividualUrl } from "./url-resolve"; + +/** + * Access individual resources from a server. + */ +export class IndividualData { + public constructor( + // Deriving classes may need to make HTTP requests of their own + protected _http: HttpClient, + private _idResolver: ResolveIndividualUrl, + private _speakingName: string + ) {} + + // Backing field for local cache, (obviously) not persisted between browser + // sessions + private _localCache: { [id: string]: TSingle } = {}; + + /** + * The individually cached resources. + */ + protected readonly _individualCache = new IndividualDescriptionCache( + this._http, + this._idResolver + ); + + /** + * @param id The id of the searched resource + * @param refresh True, if the cache must be updated + * + * @return The details of the specified resource. + */ + getSingle(id: string, refresh = false): Observable { + if (refresh) { + this._individualCache.refreshDescription(id); + } + + return this._individualCache.getDescription(id); + } + + /** + * @param id The ID of the item to retrieve from cache + * @param onMissing What to do if the item does not exist: Issue a request or return `undefined` + * @return A locally cached version of the given resource + */ + getLocal(id: string, onMissing: "undefined"): TSingle; + getLocal(id: string, onMissing: "request"): Promise; + getLocal( + id: string, + onMissing: "request" | "undefined" = "undefined" + ): TSingle | Promise { + let toReturn = this._localCache[id]; + if (!toReturn && onMissing === "request") { + return this.getSingle(id) + .pipe( + // Without taking only the first item from `getSingle`, the promise + // will never be fulfilled + first(), + // Store value as a side effect + tap((value) => this.setLocal(value)) + ) + .toPromise(); + } else if (onMissing === "request") { + return Promise.resolve(toReturn); + } else { + return toReturn; + } + } + + /** + * @param res The resource to cache locally + */ + setLocal(res: TSingle) { + console.log( + `Cache "${this._speakingName}" - Item with id ${res.id} added: `, + res + ); + this._localCache[res.id] = res; + } +} diff --git a/client/src/app/shared/serverdata/list-data.ts b/client/src/app/shared/serverdata/list-data.ts new file mode 100644 index 000000000..4039cf2b1 --- /dev/null +++ b/client/src/app/shared/serverdata/list-data.ts @@ -0,0 +1,121 @@ +import { HttpClient, HttpParams } from "@angular/common/http"; + +import { BehaviorSubject } from "rxjs"; +import { map } from "rxjs/operators"; + +import { IdentifiableResourceDescription } from "../resource.description"; + +import { + JsonApiListResponse, + isJsonApiListResponse, +} from "./json-api-response"; +import { CachedRequest } from "./request-cache"; + +/** + * Basic building block to access "typically" structured data from the server. + * May additionally also provide a local cache for objects that need to be + * persisted across multiple components. + */ +export class ListData { + public constructor( + // Deriving classes may need to make HTTP requests of their own + protected _http: HttpClient, + private _listUrl: string + ) {} + + // These parameters are passed to every listing request + private _listGetParams = new HttpParams(); + + private readonly _listTotalCount = new BehaviorSubject( + undefined + ); + + /** + * The cache of all descriptions that are available to the current user. + */ + readonly listCache = new CachedRequest(this.createListRequest()); + + /** + * An observable of all descriptions that are available to the current user. + */ + readonly list = this.listCache.value; + + /** + * @return The total number of list items available. + */ + get peekListTotalCount() { + return this._listTotalCount.value; + } + + readonly listTotalCount$ = this._listTotalCount.asObservable(); + + private createListRequest() { + return this._http + .get>(this._listUrl, { + params: this._listGetParams, + }) + .pipe( + map((response) => { + if (isJsonApiListResponse(response)) { + this._listTotalCount.next(response.meta.totalCount); + return response.data; + } else { + this._listTotalCount.next(undefined); + return response; + } + }) + ); + } + + /** + * Change the parameters that are passed to the HTTP GET requests for + * lists of data. This is used for pagination, sorting and possibly filtering. + * + * Per default changed parameters will result in a new request (and therfore an + * updated list), but if many parameters are changed peu à peuit may be smarter + * to only issue the new request once all parameters are known. + * + * @param newParams The new set of HTTP GET parameters that will be sent with every request. + * @param refresh True, if a new request should be issued immediatly. + */ + private changeListParameters(newParams: HttpParams, refresh: boolean = true) { + this._listGetParams = newParams; + if (refresh) { + this.listCache.refresh(this.createListRequest()); + } + } + + /** + * Set the ordering parameters that should be used for all subsequent + * listing requests. + */ + setListOrdering( + columnName: keyof TList, + order: "asc" | "desc" | "", + refresh: boolean = true + ) { + let newParams = + order === "" + ? this._listGetParams.delete("orderDirection").delete("orderField") + : this._listGetParams + .set("orderDirection", order) + .set("orderField", columnName.toString()); + + this.changeListParameters(newParams, refresh); + } + + /** + * Set the limits that should be used for all subsequent listing requests. + */ + setListPagination( + pageSize: number, + currentPage: number, + refresh: boolean = true + ) { + const newParams = this._listGetParams + .set("limit", pageSize.toString()) + .set("offset", (pageSize * currentPage).toString()); + + this.changeListParameters(newParams, refresh); + } +} diff --git a/client/src/app/shared/serverdata/mutate-data.ts b/client/src/app/shared/serverdata/mutate-data.ts new file mode 100644 index 000000000..105a083dc --- /dev/null +++ b/client/src/app/shared/serverdata/mutate-data.ts @@ -0,0 +1,111 @@ +import { HttpClient } from "@angular/common/http"; +import { MatSnackBar } from "@angular/material/snack-bar"; + +import { Subject } from "rxjs"; +import { first } from "rxjs/operators"; + +import { IdentifiableResourceDescription } from "../resource.description"; +import { objectOmit } from "../util"; + +import { ResolveIndividualUrl } from "./url-resolve"; + +export class MutateData { + public constructor( + // Deriving classes may need to make HTTP requests of their own + protected _http: HttpClient, + private _snackBar: MatSnackBar, + private _idResolver: ResolveIndividualUrl, + private _speakingName: string + ) {} + + private readonly _listInvalidated = new Subject(); + + readonly listInvalidated = this._listInvalidated.asObservable(); + + /** + * Updates an individual resource on the server. Uses the same + * URL as the individual data access, but with HTTP PUT. + */ + updateSingle(desc: TSingle, showErrorFeedback = true): Promise { + const toReturn = new Promise((resolve, reject) => { + const descWithoutId = objectOmit("id", desc); + + this._http + .put(this._idResolver(desc.id), descWithoutId) + .pipe(first()) + .subscribe( + (updatedDesc) => { + console.log(`Updated ${this._speakingName} with ID "${desc.id}"`); + this._snackBar.open( + `Updated ${this._speakingName} with ID "${desc.id}"`, + "", + { duration: 3000 } + ); + + this._listInvalidated.next(); + + resolve(updatedDesc); + }, + (err) => { + console.warn( + `Update failed: ${this._speakingName} with ID "${desc.id}"` + ); + + if (showErrorFeedback) { + this._snackBar.open( + `Could not update ${this._speakingName} with ID "${desc.id}"`, + "OK 😞" + ); + } else { + reject(err); + } + } + ); + }); + + return toReturn; + } + + /** + * Deletes a individual server on the server. Uses the same + * URL as the individual data access, but with HTTP DELETE. + * + * @param id The ID of the resouce. + */ + deleteSingle(id: string, showErrorFeedback = true): Promise { + const toReturn = new Promise((resolve, reject) => { + this._http + .delete(this._idResolver(id)) + .pipe(first()) + .subscribe( + (_) => { + console.log(`Deleted ${this._speakingName} with "${id}"`); + this._snackBar.open( + `Deleted ${this._speakingName} with ID "${id}"`, + "", + { duration: 3000 } + ); + + this._listInvalidated.next(); + + resolve(); + }, + (err) => { + console.warn( + `Delete failed: ${this._speakingName} with ID "${id}"` + ); + if (showErrorFeedback) { + this._snackBar.open( + `Could not delete ${this._speakingName} with ID "${id}"`, + "OK 😞" + ); + } else { + reject(err); + } + } + ); + }); + + return toReturn; + } +} diff --git a/client/src/app/shared/serverdata/project-data.service.ts b/client/src/app/shared/serverdata/project-data.service.ts index eaba4e127..084aebf3b 100644 --- a/client/src/app/shared/serverdata/project-data.service.ts +++ b/client/src/app/shared/serverdata/project-data.service.ts @@ -1,6 +1,5 @@ import { Injectable } from "@angular/core"; import { HttpClient } from "@angular/common/http"; -import { MatSnackBar } from "@angular/material/snack-bar"; import { ProjectDescription, @@ -8,25 +7,24 @@ import { } from "../project.description"; import { ServerApiService } from "./serverapi.service"; -import { DataService } from "./data-service"; +import { ListData } from "./list-data"; +import { IndividualData } from "./individual-data"; -/** - * Convenient and cached access to server side project descriptions. - */ @Injectable() -export class ProjectDataService extends DataService< - ProjectListDescription, +export class IndividualProjectDataService extends IndividualData< ProjectDescription > { - public constructor( - private _serverApi: ServerApiService, - snackBar: MatSnackBar, - http: HttpClient - ) { - super(http, snackBar, _serverApi.getProjectListUrl(), "Project"); + constructor(serverApi: ServerApiService, http: HttpClient) { + super(http, (id) => serverApi.getProjectUrl(id), "Project"); } +} - protected resolveIndividualUrl(id: string): string { - return this._serverApi.getProjectUrl(id); +/** + * Convenient and cached access to server side project descriptions. + */ +@Injectable() +export class ProjectDataService extends ListData { + public constructor(serverApi: ServerApiService, http: HttpClient) { + super(http, serverApi.getProjectListUrl()); } } diff --git a/client/src/app/shared/serverdata/serverapi.ts b/client/src/app/shared/serverdata/serverapi.ts index 9d30b8bbd..6ddaa63eb 100644 --- a/client/src/app/shared/serverdata/serverapi.ts +++ b/client/src/app/shared/serverdata/serverapi.ts @@ -169,6 +169,14 @@ export class ServerApi { return `${this._apiBaseUrl}/grammars`; } + /** + * Retrieves the URL that is used to list meta code resources (that may be used + * as the basis for a grammar). + */ + getMetaCodeResourceListUrl(): string { + return `${this._apiBaseUrl}/code_resources/by_programming_language/meta-grammar`; + } + /** * Retrieves the URL that accepts uploaded databases */ diff --git a/client/src/app/shared/serverdata/url-resolve.ts b/client/src/app/shared/serverdata/url-resolve.ts new file mode 100644 index 000000000..5649ea85a --- /dev/null +++ b/client/src/app/shared/serverdata/url-resolve.ts @@ -0,0 +1,7 @@ +/** + * Calculates the URL that can be used to retrieve the resource in question. + * + * @param id The ID of the resource to retrieve. + * @return The URL that can be used to retrieve the resource in question. + */ +export type ResolveIndividualUrl = (id: string) => string; diff --git a/client/src/app/shared/shared.module.ts b/client/src/app/shared/shared.module.ts index 3b9db6372..847a472a6 100644 --- a/client/src/app/shared/shared.module.ts +++ b/client/src/app/shared/shared.module.ts @@ -22,6 +22,9 @@ import { MatToolbarModule } from "@angular/material/toolbar"; import { MatTooltipModule } from "@angular/material/tooltip"; import { MatDialogModule } from "@angular/material/dialog"; import { MatProgressSpinnerModule } from "@angular/material/progress-spinner"; +import { MatTableModule } from "@angular/material/table"; +import { MatPaginatorModule } from "@angular/material/paginator"; +import { MatSortModule } from "@angular/material/sort"; import { AnalyticsService } from "./analytics.service"; import { BrowserService } from "./browser.service"; @@ -50,11 +53,17 @@ import { ProviderShowComponent } from "./provider-show.component"; import { EmptyComponent } from "./empty.component"; import { - GrammarDataService, - BlockLanguageDataService, + ListGrammarDataService, + IndividualGrammarDataService, + ListBlockLanguageDataService, + IndividualBlockLanguageDataService, ProjectDataService, - AdminProjectDataService, + IndividualProjectDataService, + AdminListProjectDataService, + MutateGrammarService, + MutateBlockLanguageService, } from "./serverdata"; + import { RequestResetPasswordComponent } from "./auth/request-reset-password.component"; import { ProviderButtonComponent } from "./auth/provider-button.component"; import { SignInComponent } from "./auth/sign-in.component"; @@ -74,8 +83,19 @@ import { UnexpectedLogoutInterceptor } from "./unexpected-logout.interceptor"; import { UserService } from "./auth/user.service"; import { ResourceReferencesService } from "./resource-references.service"; import { ResourceReferencesOnlineService } from "./resource-references-online.service"; +import { PaginatorTableComponent } from "./table/paginator-table.component"; -const dataServices = [GrammarDataService, BlockLanguageDataService]; +const dataServices = [ + ListGrammarDataService, + IndividualGrammarDataService, + MutateGrammarService, + ListBlockLanguageDataService, + IndividualBlockLanguageDataService, + MutateBlockLanguageService, + ProjectDataService, + IndividualProjectDataService, + AdminListProjectDataService, +]; const materialModules = [ MatToolbarModule, @@ -94,6 +114,9 @@ const materialModules = [ MatCheckboxModule, MatProgressSpinnerModule, MatDialogModule, + MatSortModule, + MatPaginatorModule, + MatTableModule, ]; /** @@ -144,6 +167,7 @@ const materialModules = [ MayPerformComponent, ProvidersAllButtonsComponent, MessageDialogComponent, + PaginatorTableComponent, ], exports: [ CommonModule, @@ -179,6 +203,7 @@ const materialModules = [ ProviderShowComponent, MessageDialogComponent, ProvidersAllButtonsComponent, + PaginatorTableComponent, ], entryComponents: [ AuthDialogComponent, @@ -196,8 +221,6 @@ export class SharedAppModule { BrowserService, FlashService, ServerApiService, - ProjectDataService, - AdminProjectDataService, VideoService, LanguageService, ToolbarService, diff --git a/client/src/app/shared/syntaxtree/grammar.description.ts b/client/src/app/shared/syntaxtree/grammar.description.ts index 9a5fa1a4a..2e1690f03 100644 --- a/client/src/app/shared/syntaxtree/grammar.description.ts +++ b/client/src/app/shared/syntaxtree/grammar.description.ts @@ -305,6 +305,9 @@ export interface GrammarListDescription { // The possible slug for URL usage slug?: string; + + // The code resource that this grammar is generated from + generatedFromId?: string; } /** @@ -348,11 +351,11 @@ export interface GrammarDescription GrammarListDescription {} /** - * A request to update a grammar. + * A request to update a grammar. The "generateFromId" field may be null to explicitly unset it. */ -export type GrammarRequestUpdateDescription = Partial< - Omit ->; +export type GrammarRequestUpdateDescription = + | Partial> + | { generatedFromId: null }; /** * @return True if the given instance satisfies "QualifiedTypeName" diff --git a/client/src/app/shared/table/paginator-table.component.ts b/client/src/app/shared/table/paginator-table.component.ts new file mode 100644 index 000000000..8eca59b85 --- /dev/null +++ b/client/src/app/shared/table/paginator-table.component.ts @@ -0,0 +1,97 @@ +import { + Component, + Input, + ViewChild, + ContentChildren, + QueryList, + AfterContentInit, + OnDestroy, + AfterViewInit, +} from "@angular/core"; +import { MatPaginator } from "@angular/material/paginator"; +import { SortDirection, MatSort } from "@angular/material/sort"; +import { MatTable, MatColumnDef } from "@angular/material/table"; + +import { ListData } from "../serverdata"; +import { Subscription } from "rxjs"; + +export interface PaginationEvent { + pageSize: number; + pageIndex: number; +} + +@Component({ + selector: "app-table-paginator", + templateUrl: "./templates/paginator-table.html", +}) +export class PaginatorTableComponent + implements AfterContentInit, AfterViewInit, OnDestroy { + // Angular Material UI to paginate + @ViewChild(MatPaginator) + private _paginator: MatPaginator; + + // The table instance that register the column definitions + @ViewChild(MatTable, { static: true }) + private _table: MatTable; + + // The column definitions that are passed in via ng-content + @ContentChildren(MatColumnDef) + columnDefs: QueryList; + + // The list that should be rendered + @Input() + listData: ListData = undefined; + + // The columns that should be rendered + @Input() + activeColumns: string[] = []; + + @Input() + sort: MatSort; + + private _subscriptions: Subscription[] = []; + + constructor() {} + + // Register the projected column definitions with the table renderer + // Found at: https://stackoverflow.com/questions/53335929/ + ngAfterContentInit(): void { + this.columnDefs.forEach((columnDef) => this._table.addColumnDef(columnDef)); + } + + ngAfterViewInit() { + // Try to register the parents `sortChange` event. Inspired by + // https://github.com/angular/components/issues/10446 + const sub = this.sort.sortChange.subscribe(() => { + this.onChangeSort(this.sort.active, this.sort.direction); + }); + this._subscriptions.push(sub); + } + + ngOnDestroy() { + this._subscriptions.forEach((s) => s.unsubscribe()); + this._subscriptions = []; + } + + get resultsLength$() { + return this.listData.listTotalCount$; + } + + onChangePagination() { + this.listData.setListPagination( + this._paginator.pageSize, + this._paginator.pageIndex + ); + } + + /** + * User has requested different sorting options + */ + onChangeSort( + active: string, + direction: SortDirection, + refresh: boolean = true + ) { + this.listData.setListOrdering(active, direction, refresh); + } +} diff --git a/client/src/app/shared/table/templates/paginator-table.html b/client/src/app/shared/table/templates/paginator-table.html new file mode 100644 index 000000000..325f5ac20 --- /dev/null +++ b/client/src/app/shared/table/templates/paginator-table.html @@ -0,0 +1,12 @@ + + + + +
+ diff --git a/client/src/app/shared/util.spec.ts b/client/src/app/shared/util.spec.ts index 2331f9731..74425bf52 100644 --- a/client/src/app/shared/util.spec.ts +++ b/client/src/app/shared/util.spec.ts @@ -14,31 +14,27 @@ describe("Utility: encodeUriParameters", () => { describe("Utility: isValidResourceId", () => { it("identifies valid IDs", () => { - expect( - Util.isValidResourceId("00000000-1111-2222-3333-444444444444") - ).toEqual(true); - expect( - Util.isValidResourceId("AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE") - ).toEqual(true); - expect( - Util.isValidResourceId("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee") - ).toEqual(true); + expect(Util.isValidId("00000000-1111-2222-3333-444444444444")).toEqual( + true + ); + expect(Util.isValidId("AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE")).toEqual( + true + ); + expect(Util.isValidId("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")).toEqual( + true + ); - expect( - Util.isValidResourceId("4f1f31c8-4ea3-42bd-9ba3-76a4c1d459b0") - ).toEqual(true); - expect( - Util.isValidResourceId("4F1F31C8-4EA3-42BD-9BA3-76A4C1D459B0") - ).toEqual(true); + expect(Util.isValidId("4f1f31c8-4ea3-42bd-9ba3-76a4c1d459b0")).toEqual( + true + ); + expect(Util.isValidId("4F1F31C8-4EA3-42BD-9BA3-76A4C1D459B0")).toEqual( + true + ); }); it("identifies invalid IDs", () => { - expect(Util.isValidResourceId("00000000111122223333444444444444")).toEqual( - false - ); - expect(Util.isValidResourceId("AAAAAAAABBBBCCCCDDDDEEEEEEEEEEEE")).toEqual( - false - ); + expect(Util.isValidId("00000000111122223333444444444444")).toEqual(false); + expect(Util.isValidId("AAAAAAAABBBBCCCCDDDDEEEEEEEEEEEE")).toEqual(false); }); }); diff --git a/client/src/app/shared/util.ts b/client/src/app/shared/util.ts index f84427b5e..bbaf279a6 100644 --- a/client/src/app/shared/util.ts +++ b/client/src/app/shared/util.ts @@ -29,14 +29,13 @@ export function assertValidResourceName(name: string): void { } /** - * Determines whether the given identifier can be used as a - * resource ID in esqulino. + * Determines whether the given identifier can be used as a ID. * * @param id The id to test. * * @return True, if the given id could be used. */ -export function isValidResourceId(id: string): boolean { +export function isValidId(id: string): boolean { return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( id ); diff --git a/schema/json/BlockLanguageListItemDescription.json b/schema/json/BlockLanguageListItemDescription.json new file mode 100644 index 000000000..88be18df0 --- /dev/null +++ b/schema/json/BlockLanguageListItemDescription.json @@ -0,0 +1,54 @@ +{ + "$ref": "#/definitions/BlockLanguageListItemDescription", + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "BlockLanguageListItemDescription": { + "additionalProperties": false, + "description": "The server hands out additional information that is only used for display purposes.", + "properties": { + "blockLanguageGeneratorId": { + "description": "The ID of the block language that may have been used to generate this\nblock language.", + "type": "string" + }, + "createdAt": { + "description": "Date & time this resource was created", + "type": "string" + }, + "defaultProgrammingLanguageId": { + "description": "The programming language this block language uses by default.", + "type": "string" + }, + "generated": { + "type": "boolean" + }, + "grammarId": { + "description": "The grammar that this block language may visualize.", + "type": "string" + }, + "id": { + "description": "The internal ID of this language model.", + "type": "string" + }, + "name": { + "description": "The name that should be displayed to the user.", + "type": "string" + }, + "slug": { + "description": "A unique (but possibly empty) id. If this is undefined the language has\nno builtin counterpart on the client.", + "type": "string" + }, + "updatedAt": { + "description": "Date & time this resource was updated the last time", + "type": "string" + } + }, + "required": [ + "defaultProgrammingLanguageId", + "generated", + "id", + "name" + ], + "type": "object" + } + } +} \ No newline at end of file diff --git a/schema/json/BlockLanguageListResponseDescription.json b/schema/json/BlockLanguageListResponseDescription.json index f7b8f020d..1a755bdc6 100644 --- a/schema/json/BlockLanguageListResponseDescription.json +++ b/schema/json/BlockLanguageListResponseDescription.json @@ -2,7 +2,7 @@ "$ref": "#/definitions/BlockLanguageListResponseDescription", "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { - "BlockLanguageListResponseDescription": { + "BlockLanguageListItemDescription": { "additionalProperties": false, "description": "The server hands out additional information that is only used for display purposes.", "properties": { @@ -49,6 +49,38 @@ "name" ], "type": "object" + }, + "BlockLanguageListResponseDescription": { + "$ref": "#/definitions/JsonApiListResponse_BlockLanguageListItemDescription_" + }, + "JsonApiListResponse_BlockLanguageListItemDescription_": { + "additionalProperties": false, + "description": "A response that also informs about the total number of items\nthat are available.", + "properties": { + "data": { + "items": { + "$ref": "#/definitions/BlockLanguageListItemDescription" + }, + "type": "array" + }, + "meta": { + "additionalProperties": false, + "properties": { + "totalCount": { + "type": "number" + } + }, + "required": [ + "totalCount" + ], + "type": "object" + } + }, + "required": [ + "data", + "meta" + ], + "type": "object" } } } \ No newline at end of file diff --git a/schema/json/GrammarDescription.json b/schema/json/GrammarDescription.json index 718f1048c..6ff9cb506 100644 --- a/schema/json/GrammarDescription.json +++ b/schema/json/GrammarDescription.json @@ -49,6 +49,9 @@ "foreignTypes": { "$ref": "#/definitions/NamedLanguages" }, + "generatedFromId": { + "type": "string" + }, "id": { "type": "string" }, diff --git a/schema/json/GrammarListDescription.json b/schema/json/GrammarListDescription.json index 6d4e63512..3f45e1e96 100644 --- a/schema/json/GrammarListDescription.json +++ b/schema/json/GrammarListDescription.json @@ -6,6 +6,9 @@ "additionalProperties": false, "description": "Listing data about grammars", "properties": { + "generatedFromId": { + "type": "string" + }, "id": { "type": "string" }, diff --git a/schema/json/GrammarRequestUpdateDescription.json b/schema/json/GrammarRequestUpdateDescription.json index ceb6a7311..3c9b532e6 100644 --- a/schema/json/GrammarRequestUpdateDescription.json +++ b/schema/json/GrammarRequestUpdateDescription.json @@ -43,35 +43,54 @@ "type": "object" }, "GrammarRequestUpdateDescription": { - "additionalProperties": false, - "description": "A request to update a grammar.", - "properties": { - "foreignTypes": { - "$ref": "#/definitions/NamedLanguages" - }, - "includedGrammars": { - "items": { - "type": "string" + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "foreignTypes": { + "$ref": "#/definitions/NamedLanguages" + }, + "generatedFromId": { + "type": "string" + }, + "includedGrammars": { + "items": { + "type": "string" + }, + "type": "array" + }, + "name": { + "type": "string" + }, + "programmingLanguageId": { + "type": "string" + }, + "root": { + "$ref": "#/definitions/QualifiedTypeName" + }, + "slug": { + "type": "string" + }, + "types": { + "$ref": "#/definitions/NamedLanguages" + } }, - "type": "array" - }, - "name": { - "type": "string" - }, - "programmingLanguageId": { - "type": "string" - }, - "root": { - "$ref": "#/definitions/QualifiedTypeName" - }, - "slug": { - "type": "string" + "type": "object" }, - "types": { - "$ref": "#/definitions/NamedLanguages" + { + "additionalProperties": false, + "properties": { + "generatedFromId": { + "type": "null" + } + }, + "required": [ + "generatedFromId" + ], + "type": "object" } - }, - "type": "object" + ], + "description": "A request to update a grammar." }, "LengthRestrictionDescription": { "additionalProperties": false, diff --git a/schema/json/Makefile.json b/schema/json/Makefile.json index 920898346..84b45cd12 100644 --- a/schema/json/Makefile.json +++ b/schema/json/Makefile.json @@ -11,7 +11,7 @@ TYPESCRIPT_JSON_SCHEMA_BIN = ./node_modules/.bin/ts-json-schema-generator # TYPESCRIPT_JSON_SCHEMA_BIN = ./node_modules/ts-json-schema-generator/bin/ts-json-schema-generator # Names of the schema files to generate -JSON_SCHEMA_FILES = AlterSchemaRequestDescription.json ArbitraryQueryRequestDescription.json BlockLanguageDescription.json BlockLanguageDocument.json BlockLanguageListDescription.json BlockLanguageListResponseDescription.json CodeResourceDescription.json GrammarDescription.json GrammarDocument.json GrammarDatabaseBlob.json GrammarListDescription.json GrammarRequestUpdateDescription.json NodeDescription.json ProjectCreationRequest.json ProjectDescription.json ProjectFullDescription.json ProjectListDescription.json ProjectSourceDescription.json ProjectUpdateDescription.json ProjectUsesBlockLanguageDescription.json RequestTabularInsertDescription.json ResponseTabularInsertDescription.json TableDescription.json NewsDescription.json NewsUpdateDescription.json NewsFrontpageDescription.json ServerProviderDescription.json CodeResourceRequestUpdateDescription.json +JSON_SCHEMA_FILES = AlterSchemaRequestDescription.json ArbitraryQueryRequestDescription.json BlockLanguageDescription.json BlockLanguageDocument.json BlockLanguageListDescription.json BlockLanguageListItemDescription.json BlockLanguageListResponseDescription.json CodeResourceDescription.json CodeResourceRequestUpdateDescription.json GrammarDatabaseBlob.json GrammarDescription.json GrammarDocument.json GrammarListDescription.json GrammarRequestUpdateDescription.json NewsDescription.json NewsFrontpageDescription.json NewsUpdateDescription.json NodeDescription.json ProjectCreationRequest.json ProjectDescription.json ProjectFullDescription.json ProjectListDescription.json ProjectSourceDescription.json ProjectUpdateDescription.json ProjectUsesBlockLanguageDescription.json RequestTabularInsertDescription.json ResponseTabularInsertDescription.json ServerProviderDescription.json TableDescription.json ################################## # Not so nice: Repeated rules @@ -61,6 +61,9 @@ BlockLanguageDescription.json : $(SRC_PATH)/shared/block/block-language.descript BlockLanguageListDescription.json : $(SRC_PATH)/shared/block/block-language.description.ts $(CONVERT_COMMAND) +BlockLanguageListItemDescription.json : $(SRC_PATH)/shared/block/block-language.description.ts + $(CONVERT_COMMAND) + BlockLanguageListResponseDescription.json : $(SRC_PATH)/shared/block/block-language.description.ts $(CONVERT_COMMAND) diff --git a/schema/json/ProjectDescription.json b/schema/json/ProjectDescription.json index 648c2c3e9..59277a53f 100644 --- a/schema/json/ProjectDescription.json +++ b/schema/json/ProjectDescription.json @@ -561,6 +561,9 @@ "foreignTypes": { "$ref": "#/definitions/NamedLanguages" }, + "generatedFromId": { + "type": "string" + }, "id": { "type": "string" }, diff --git a/schema/json/ProjectFullDescription.json b/schema/json/ProjectFullDescription.json index 97c9cfdf7..aa5351f38 100644 --- a/schema/json/ProjectFullDescription.json +++ b/schema/json/ProjectFullDescription.json @@ -664,6 +664,9 @@ "foreignTypes": { "$ref": "#/definitions/NamedLanguages" }, + "generatedFromId": { + "type": "string" + }, "id": { "type": "string" }, diff --git a/server/app/controllers/block_languages_controller.rb b/server/app/controllers/block_languages_controller.rb index 5582d680d..056e885ab 100644 --- a/server/app/controllers/block_languages_controller.rb +++ b/server/app/controllers/block_languages_controller.rb @@ -1,10 +1,10 @@ # Manages operations on block languages class BlockLanguagesController < ApplicationController + include PaginationHelper # List all existing block languages and embed additional information # that is relevant when listing def index - render :json => BlockLanguage.scope_list - .map{|b| b.to_list_api_response(true)} + render :json => pagination_response(BlockLanguage,BlockLanguage.scope_list,options:{include_list_calculations:true}) end # Find a single block language by ID or by slug diff --git a/server/app/controllers/code_resources_controller.rb b/server/app/controllers/code_resources_controller.rb index 5146fd1fb..f834059f4 100644 --- a/server/app/controllers/code_resources_controller.rb +++ b/server/app/controllers/code_resources_controller.rb @@ -14,17 +14,14 @@ def index_by_programming_language # Create a new resource that is part of a specific project def create - project_slug = params[:project_id] - proj = Project.find_by slug: project_slug - if proj - res = proj.code_resources.new(code_resource_create_params) - if res.save - render :json => res.to_full_api_response, :status => 200 - else - render :json => { 'errors' => res.errors }, :status => 400 - end + project_id = params[:project_id] + proj = Project.find_by_slug_or_id! project_id + + res = proj.code_resources.new(code_resource_create_params) + if res.save + render :json => res.to_full_api_response, :status => 200 else - raise UnknownProjectError, project_id + render :json => { 'errors' => res.errors }, :status => 400 end end diff --git a/server/app/controllers/grammars_controller.rb b/server/app/controllers/grammars_controller.rb index b7ff00a3f..7df053cd1 100644 --- a/server/app/controllers/grammars_controller.rb +++ b/server/app/controllers/grammars_controller.rb @@ -1,11 +1,12 @@ # Manages operations on grammars class GrammarsController < ApplicationController include UserHelper + include PaginationHelper include JsonSchemaHelper # List all existing grammars def index - render :json => Grammar.scope_list.map{|g| g.to_list_api_response} + render :json => pagination_response(Grammar,Grammar.scope_list,options:{}) end # Find a single grammar @@ -41,9 +42,15 @@ def update grammar.assign_attributes basic_params grammar.model = model_params - if grammar.save + # Possibly update the code resource that this grammar is based on + if params.key? "generatedFromId" + grammar.generated_from_id = params.fetch("generatedFromId", nil) + end + + begin + grammar.save! render status: 204 - else + rescue ActiveRecord::InvalidForeignKey, ActiveRecord::RecordInvalid render json: { 'errors' => grammar.errors.as_json }, :status => 400 end end @@ -64,7 +71,7 @@ def destroy def related_block_languages render :json => BlockLanguage.scope_list .where(grammar_id: id_params[:id]) - .map{|b| b.to_list_api_response} + .map{|b| b.to_list_api_response(options:{include_list_calculations: false})} end # List all code resources that depend on a single grammar @@ -78,7 +85,6 @@ def code_resources_gallery .map { |c| c.to_full_api_response } end - private # These parameters may be used to identify a grammar diff --git a/server/app/controllers/projects_controller.rb b/server/app/controllers/projects_controller.rb index ea4f81ede..c3816a660 100644 --- a/server/app/controllers/projects_controller.rb +++ b/server/app/controllers/projects_controller.rb @@ -3,16 +3,17 @@ class ProjectsController < ApplicationController include ProjectsHelper include JsonSchemaHelper include UserHelper + include PaginationHelper # Lists all public projects def index - render json: index_pagination_response(Project.only_public) + render json: pagination_response(Project,Project.only_public,options:{}) end # Lists all projects that exist in the system (if the user is an admin) def index_admin authorize Project, :list_all? - render json: index_pagination_response(Project.all) + render json: pagination_response(Project,Project.all,options:{}) end # Retrieves all information about a single project. This is the only @@ -80,28 +81,6 @@ def preview_image private - # Pagination for any query that lists projects - def index_pagination_response(query) - order_key = project_list_params.fetch("order_field", "name") - order_dir = project_list_params.fetch("order_direction", "asc") - - if (not Project.has_attribute? order_key or not ["asc", "desc"].include? order_dir) - raise EsqulinoError::InvalidOrder.new(order_key, order_dir) - end - - paginated_query = query - .order({ order_key => order_dir}) - .limit(project_list_params.fetch("limit", 100)) - .offset(project_list_params.fetch("offset", 0)) - - return { - data: paginated_query.map{|p| p.to_list_api_response}, - meta: { - totalCount: query.count - } - } - end - # These attributes are mandatory when a project is created def project_creation_params to_return = params.permit(:name, :slug) @@ -118,12 +97,6 @@ def project_update_params .transform_keys { |k| k.underscore } end - # These attributes - def project_list_params - params.permit(:limit, :offset, :orderField, :orderDirection) - .transform_keys { |k| k.underscore } - end - # The references to block languages that are part of this project. # All of these references may be updated. def project_used_block_languages_params diff --git a/server/app/helpers/pagination_helper.rb b/server/app/helpers/pagination_helper.rb new file mode 100644 index 000000000..653f7a7f7 --- /dev/null +++ b/server/app/helpers/pagination_helper.rb @@ -0,0 +1,35 @@ +module PaginationHelper + # Pagination for any query that will be displayed in a listing + def pagination_response(model, query,options:{}) + + order_key = list_params.stringify_keys.fetch("order_field", "name") + order_dir = list_params.stringify_keys.fetch("order_direction", "asc") + + if (not model.has_attribute? order_key or not ["asc", "desc"].include? order_dir) + raise EsqulinoError::InvalidOrder.new(order_key, order_dir) + end + + paginated_query = query + .order({ order_key => order_dir}) + .limit(list_params.fetch("limit", 100)) + .offset(list_params.fetch("offset", 0)) + + return { + data: paginated_query.map{|p| p.to_list_api_response(options:options)}, + meta: { + # size() runs count() or length(), depending on if the collection has already been loaded or not. + # count() will be executed if the collection hasn't been loaded yet + # length() will be executed if the collection already has been loaded + # http://web.archive.org/web/20100210204319/http://blog.hasmanythrough.com/2008/2/27/count-length-size + totalCount: query.size + } + } + end + + # These attributes are used in all listings + def list_params + params.permit(:limit, :offset, :orderField, :orderDirection) + .transform_keys { |k| k.underscore } + end + +end \ No newline at end of file diff --git a/server/app/models/application_record.rb b/server/app/models/application_record.rb index 86fdce8c9..11a7c604c 100644 --- a/server/app/models/application_record.rb +++ b/server/app/models/application_record.rb @@ -25,7 +25,9 @@ def to_json_api_response(compact: true) # Update the created_at and updated_at fields ["created_at", "updated_at"].each do |k| - to_return[k] = to_return[k].to_s + if to_return.key?(k) + to_return[k] = to_return[k].to_s + end end # All keys should be in "camelCase" diff --git a/server/app/models/block_language.rb b/server/app/models/block_language.rb index 73a75ad04..bed881bd3 100644 --- a/server/app/models/block_language.rb +++ b/server/app/models/block_language.rb @@ -33,25 +33,23 @@ class BlockLanguage < ApplicationRecord # full access to the block language. This usually happens when the # client is working with the editor. def to_full_api_response - to_list_api_response.merge(self.model) + to_list_api_response + .except("model") + .merge(self.model) end # Computes a hash that may be sent back to the client if only superficial # information is required. This usually happens when the client attempts # to list available block languages. # - # @param include_list_calculations [boolean] + # @param options {include_list_calculations [boolean]} # True, if certain calculated values should be part of the response - def to_list_api_response(include_list_calculations = false) - if include_list_calculations then - to_json_api_response - .slice("id", "slug", "name", "defaultProgrammingLanguageId", - "blockLanguageGeneratorId", "grammarId", "generated") - else - to_json_api_response - .slice("id", "slug", "name", "defaultProgrammingLanguageId", - "blockLanguageGeneratorId", "grammarId") + def to_list_api_response(options:{}) + unless options.key?(:include_list_calculations) and options[:include_list_calculations] then + return to_json_api_response + .except("generated") end + to_json_api_response end end diff --git a/server/app/models/grammar.rb b/server/app/models/grammar.rb index 3cf1ae29b..c865cde35 100644 --- a/server/app/models/grammar.rb +++ b/server/app/models/grammar.rb @@ -62,8 +62,8 @@ def to_full_api_response # Computes a hash that may be sent back to the client if only superficial # information is required. This usually happens when the client attempts # to list available grammars. - def to_list_api_response + def to_list_api_response(options: {}) to_json_api_response - .slice("id", "slug", "name", "technicalName", "programmingLanguageId") + .slice("id", "slug", "name", "technicalName", "programmingLanguageId", "generatedFromId") end end diff --git a/server/app/models/project.rb b/server/app/models/project.rb index 146d657e0..4b5730d58 100644 --- a/server/app/models/project.rb +++ b/server/app/models/project.rb @@ -136,7 +136,7 @@ def to_project_api_response # Hands out just enough data about this project to allow a nice listing of available # projects in the client. - def to_list_api_response + def to_list_api_response(options:{}) to_json_api_response end diff --git a/server/spec/helpers/pagination_helper_spec.rb b/server/spec/helpers/pagination_helper_spec.rb new file mode 100644 index 000000000..91d5a1dfa --- /dev/null +++ b/server/spec/helpers/pagination_helper_spec.rb @@ -0,0 +1,34 @@ +require "rails_helper" + +RSpec.describe PaginationHelper, type: :helper do + describe "Grammar#pagination_response" do + it "throws exception when using invalid order keys" do + allow(helper).to receive(:list_params).and_return(order_field:"michWirdEsNieAlsAttributGeben") + expect{helper.pagination_response(Grammar,Grammar.scope_list,options: {})}.to raise_error(EsqulinoError::InvalidOrder) + end + it "works fine with a valid key" do + allow(helper).to receive(:list_params).and_return(order_field:"name") + expect{helper.pagination_response(Grammar,Grammar.scope_list,options: {})}.not_to raise_error(EsqulinoError::InvalidOrder) + end + end + describe "Project#pagination_response" do + it "Project sort only by valid order keys" do + allow(helper).to receive(:list_params).and_return(order_field:"michWirdEsNieAlsAttributGeben") + expect{helper.pagination_response(Project,Project.all,options: {})}.to raise_error(EsqulinoError::InvalidOrder) + end + it "works fine with a valid key" do + allow(helper).to receive(:list_params).and_return(order_field:"name") + expect{helper.pagination_response(Project,Project.scope_list,options: {})}.not_to raise_error(EsqulinoError::InvalidOrder) + end + end + describe "BlockLanguage#pagination_response" do + it "BlockLanguage sort only by valid order keys" do + allow(helper).to receive(:list_params).and_return(order_field:"michWirdEsNieAlsAttributGeben") + expect{helper.pagination_response(BlockLanguage,BlockLanguage.scope_list,options: {})}.to raise_error(EsqulinoError::InvalidOrder) + end + it "works fine with a valid key" do + allow(helper).to receive(:list_params).and_return(order_field:"name") + expect{helper.pagination_response(BlockLanguage,BlockLanguage.scope_list,options: {})}.not_to raise_error(EsqulinoError::InvalidOrder) + end + end +end \ No newline at end of file diff --git a/server/spec/models/block_language_spec.rb b/server/spec/models/block_language_spec.rb index 186df35ac..36debc14f 100644 --- a/server/spec/models/block_language_spec.rb +++ b/server/spec/models/block_language_spec.rb @@ -42,13 +42,13 @@ context "to_full_api_response" do it "works for empty languages" do - b = FactoryBot.build(:block_language, id: SecureRandom.uuid) - api_response = b.to_full_api_response - + block_language = FactoryBot.build(:block_language, id: SecureRandom.uuid) + api_response = block_language.to_full_api_response + expect(api_response).to validate_against "BlockLanguageDescription" - expect(api_response['id']).to eq b.id - expect(api_response['name']).to eq b.name - expect(api_response['slug']).to eq b.slug + expect(api_response['id']).to eq block_language.id + expect(api_response['name']).to eq block_language.name + expect(api_response['slug']).to eq block_language.slug end end @@ -64,15 +64,26 @@ context "to_list_api_response" do it "with 'generated' field" do b = FactoryBot.create(:block_language, id: SecureRandom.uuid) + api_response = BlockLanguage.scope_list.first.to_list_api_response(options:{include_list_calculations:true}) - api_response = BlockLanguage.scope_list.first.to_list_api_response(true) - - expect(api_response).to validate_against "BlockLanguageListResponseDescription" + expect(api_response).to validate_against "BlockLanguageListItemDescription" expect(api_response['id']).to eq b.id expect(api_response['name']).to eq b.name expect(api_response['slug']).to eq b.slug expect(api_response['generated']).to equal false end + + it "without 'generated' field" do + b = FactoryBot.create(:block_language, id: SecureRandom.uuid) + api_response = BlockLanguage.scope_list.first.to_list_api_response(options:{include_list_calculations:false}) + + expect(api_response).to validate_against "BlockLanguageListDescription" + expect(api_response['id']).to eq b.id + expect(api_response['name']).to eq b.name + expect(api_response['slug']).to eq b.slug + expect(api_response).not_to have_key("generated") + end + end it "can be valid" do diff --git a/server/spec/models/code_resource_spec.rb b/server/spec/models/code_resource_spec.rb index c384b1aef..32d437815 100644 --- a/server/spec/models/code_resource_spec.rb +++ b/server/spec/models/code_resource_spec.rb @@ -140,6 +140,7 @@ queried = CodeResource.list_by_programming_language("sql") expect(queried).to eq([res]) expect(queried[0].attributes.keys).to eq(["id", "name"]) + expect(queried[0].to_json_api_response.keys).to eq(["id", "name"]) end end diff --git a/server/spec/requests/block_languages_spec.rb b/server/spec/requests/block_languages_spec.rb index c0ab8164e..57d74a086 100644 --- a/server/spec/requests/block_languages_spec.rb +++ b/server/spec/requests/block_languages_spec.rb @@ -8,7 +8,7 @@ get "/api/block_languages" expect(response).to have_http_status(200) - expect(JSON.parse(response.body).length).to eq 0 + expect(JSON.parse(response.body)['data'].length).to eq 0 end it 'lists a single block language' do @@ -18,8 +18,82 @@ expect(response).to have_http_status(200) json_data = JSON.parse(response.body) - expect(json_data.length).to eq 1 - expect(json_data[0]).to validate_against "BlockLanguageListResponseDescription" + + expect(json_data['data'].length).to eq 1 + expect(json_data).to validate_against "BlockLanguageListResponseDescription" + end + + it 'limit' do + FactoryBot.create(:block_language) + FactoryBot.create(:block_language) + FactoryBot.create(:block_language) + + get "/api/block_languages?limit=1" + expect(JSON.parse(response.body)['data'].length).to eq 1 + + get "/api/block_languages?limit=2" + expect(JSON.parse(response.body)['data'].length).to eq 2 + + get "/api/block_languages?limit=3" + expect(JSON.parse(response.body)['data'].length).to eq 3 + + get "/api/block_languages?limit=4" + expect(JSON.parse(response.body)['data'].length).to eq 3 + end + + describe 'order by' do + before do + FactoryBot.create(:block_language, name: 'cccc', slug: 'cccc') + FactoryBot.create(:block_language, name: 'aaaa', slug: 'aaaa') + FactoryBot.create(:block_language, name: 'bbbb', slug: 'bbbb') + end + + it 'nonexistant column' do + get "/api/block_languages?orderField=nonexistant" + + expect(response.status).to eq 400 + end + + it 'slug' do + get "/api/block_languages?orderField=slug" + json_data = JSON.parse(response.body)['data'] + + expect(json_data.map { |p| p['slug'] }).to eq ['aaaa', 'bbbb', 'cccc'] + end + + it 'slug invalid direction' do + get "/api/block_languages?orderField=slug&orderDirection=north" + + expect(response.status).to eq 400 + end + + it 'slug desc' do + get "/api/block_languages?orderField=slug&orderDirection=desc" + json_data = JSON.parse(response.body)['data'] + + expect(json_data.map { |p| p['slug'] }).to eq ['cccc', 'bbbb', 'aaaa'] + end + + it 'slug asc' do + get "/api/block_languages?orderField=slug&orderDirection=asc" + json_data = JSON.parse(response.body)['data'] + + expect(json_data.map { |p| p['slug'] }).to eq ['aaaa', 'bbbb', 'cccc'] + end + + it 'name desc' do + get "/api/block_languages?orderField=name&orderDirection=desc" + json_data = JSON.parse(response.body)['data'] + + expect(json_data.map { |p| p['name'] }).to eq ['cccc', 'bbbb', 'aaaa'] + end + + it 'name asc' do + get "/api/block_languages?orderField=name&orderDirection=asc" + json_data = JSON.parse(response.body)['data'] + + expect(json_data.map { |p| p['name'] }).to eq ['aaaa', 'bbbb', 'cccc'] + end end end @@ -126,7 +200,7 @@ expect(response.status).to eq(200) expect(json_data).to validate_against "BlockLanguageDescription" - expect(json_data.except("id")).to eq block_lang_model + expect(json_data.except("id", "createdAt", "updatedAt")).to eq block_lang_model end describe 'PUT /api/block_languages/:id' do diff --git a/server/spec/requests/code_resources_spec.rb b/server/spec/requests/code_resources_spec.rb index 080073c7d..5ab31852c 100644 --- a/server/spec/requests/code_resources_spec.rb +++ b/server/spec/requests/code_resources_spec.rb @@ -50,6 +50,35 @@ def url_for(lang) end describe "CREATE" do + it "properly fails on missing projects (slug)" do + resource = FactoryBot.build(:code_resource) + + post "/api/project/invalid/code_resources/", + :headers => json_headers, + :params => { + "name" => resource.name, + "blockLanguageId" => resource.block_language_id, + "programmingLanguageId" => resource.programming_language_id, + }.to_json + + expect(response.status).to eq(404) + end + + it "properly fails on missing projects (uuid)" do + resource = FactoryBot.create(:code_resource) + + # Use the ID of the resource as ID of the project + post "/api/project/#{resource.id}/code_resources/", + :headers => json_headers, + :params => { + "name" => resource.name, + "blockLanguageId" => resource.block_language_id, + "programmingLanguageId" => resource.programming_language_id, + }.to_json + + expect(response.status).to eq(404) + end + it "works with default factory bot object" do resource = FactoryBot.build(:code_resource) diff --git a/server/spec/requests/grammars_spec.rb b/server/spec/requests/grammars_spec.rb index 9dc7a2a28..1e73a39c1 100644 --- a/server/spec/requests/grammars_spec.rb +++ b/server/spec/requests/grammars_spec.rb @@ -8,7 +8,7 @@ get "/api/grammars" expect(response).to have_http_status(200) - expect(JSON.parse(response.body).length).to eq 0 + expect(JSON.parse(response.body)['data'].length).to eq 0 end it 'lists a grammar' do @@ -19,8 +19,81 @@ json_data = JSON.parse(response.body) - expect(json_data.length).to eq 1 - expect(json_data[0]).to validate_against "GrammarListDescription" + expect(json_data['data'].length).to eq 1 + expect(json_data['data'][0]).to validate_against "GrammarListDescription" + end + + it 'limit' do + FactoryBot.create(:grammar) + FactoryBot.create(:grammar) + FactoryBot.create(:grammar) + + get "/api/grammars?limit=1" + expect(JSON.parse(response.body)['data'].length).to eq 1 + + get "/api/grammars?limit=2" + expect(JSON.parse(response.body)['data'].length).to eq 2 + + get "/api/grammars?limit=3" + expect(JSON.parse(response.body)['data'].length).to eq 3 + + get "/api/grammars?limit=4" + expect(JSON.parse(response.body)['data'].length).to eq 3 + end + + describe 'order by' do + before do + FactoryBot.create(:grammar, name: 'cccc', slug: 'cccc') + FactoryBot.create(:grammar, name: 'aaaa', slug: 'aaaa') + FactoryBot.create(:grammar, name: 'bbbb', slug: 'bbbb') + end + + it 'nonexistant column' do + get "/api/grammars?orderField=nonexistant" + + expect(response.status).to eq 400 + end + + it 'slug' do + get "/api/grammars?orderField=slug" + json_data = JSON.parse(response.body)['data'] + + expect(json_data.map { |p| p['slug'] }).to eq ['aaaa', 'bbbb', 'cccc'] + end + + it 'slug invalid direction' do + get "/api/grammars?orderField=slug&orderDirection=north" + + expect(response.status).to eq 400 + end + + it 'slug desc' do + get "/api/grammars?orderField=slug&orderDirection=desc" + json_data = JSON.parse(response.body)['data'] + + expect(json_data.map { |p| p['slug'] }).to eq ['cccc', 'bbbb', 'aaaa'] + end + + it 'slug asc' do + get "/api/grammars?orderField=slug&orderDirection=asc" + json_data = JSON.parse(response.body)['data'] + + expect(json_data.map { |p| p['slug'] }).to eq ['aaaa', 'bbbb', 'cccc'] + end + + it 'name desc' do + get "/api/grammars?orderField=name&orderDirection=desc" + json_data = JSON.parse(response.body)['data'] + + expect(json_data.map { |p| p['name'] }).to eq ['cccc', 'bbbb', 'aaaa'] + end + + it 'name asc' do + get "/api/grammars?orderField=name&orderDirection=asc" + json_data = JSON.parse(response.body)['data'] + + expect(json_data.map { |p| p['name'] }).to eq ['aaaa', 'bbbb', 'cccc'] + end end end @@ -51,6 +124,20 @@ get "/api/grammars/0" expect(response).to have_http_status(404) end + + it 'includes the CodeResource a grammar is based on' do + meta_code_resource = FactoryBot.create(:code_resource, :grammar_single_type) + original = FactoryBot.create(:grammar, generated_from: meta_code_resource) + + get "/api/grammars/#{original.id}" + expect(response).to have_http_status(200) + + json_data = JSON.parse(response.body) + + expect(json_data).to validate_against "GrammarDescription" + expect(json_data["generatedFromId"]).to eq meta_code_resource.id + + end end describe 'POST /api/grammars' do @@ -194,7 +281,45 @@ expect(Grammar.find_by id: new_id).to be nil end + it 'Set the a CodeResouce that a grammar is generated from' do + original = FactoryBot.create(:grammar) + meta_code_resource = FactoryBot.create(:code_resource, :grammar_single_type) + + put "/api/grammars/#{original.id}", + :headers => json_headers, + :params => { "generatedFromId" => meta_code_resource.id }.to_json + + original.reload + + expect(original.generated_from).to eq meta_code_resource + end + + it 'Attempt to set a non-existant CodeResouce that a grammar is generated from' do + original = FactoryBot.create(:grammar) + ref_id = SecureRandom.uuid + + put "/api/grammars/#{original.id}", + :headers => json_headers, + :params => { "generatedFromId" => ref_id }.to_json + + expect(response.status).to eq(400) + + original.reload + expect(original.generated_from).to eq nil + end + + it 'Unset the a CodeResouce that a grammar is generated from' do + meta_code_resource = FactoryBot.create(:code_resource, :grammar_single_type) + original = FactoryBot.create(:grammar, generated_from: meta_code_resource) + + put "/api/grammars/#{original.id}", + :headers => json_headers, + :params => { "generatedFromId" => nil }.to_json + original.reload + + expect(original.generated_from).to be nil + end end describe 'DELETE /api/grammars/:grammarId' do