From b9254a4397b7e3b64eef6f9d4a9279713cac3a85 Mon Sep 17 00:00:00 2001 From: Xander Vertegaal Date: Sat, 7 Dec 2024 15:42:37 +0100 Subject: [PATCH 1/9] Add Historical Person form --- .../person/mutations/UpdateAgentMutation.py | 1 + backend/person/queries.py | 10 +- frontend/generated/graphql.ts | 63 +++++++ frontend/generated/schema.graphql | 2 + .../agent-form/agent-form.component.html | 2 +- .../agent-form/agent-form.module.ts | 2 + ...gent-historical-person-form.component.html | 17 ++ ...gent-historical-person-form.component.scss | 0 ...t-historical-person-form.component.spec.ts | 21 +++ .../agent-historical-person-form.component.ts | 178 ++++++++++++++++++ .../agent-historical-person-form.graphql | 24 +++ .../episode-source-text-form.component.ts | 3 - .../shared/data-entry-shared.module.ts | 7 +- .../historical-person-select.component.html | 46 +++++ .../historical-person-select.component.scss | 0 ...historical-person-select.component.spec.ts | 21 +++ .../historical-person-select.component.ts | 89 +++++++++ 17 files changed, 479 insertions(+), 7 deletions(-) create mode 100644 frontend/src/app/data-entry/agent-form/agent-historical-person-form/agent-historical-person-form.component.html create mode 100644 frontend/src/app/data-entry/agent-form/agent-historical-person-form/agent-historical-person-form.component.scss create mode 100644 frontend/src/app/data-entry/agent-form/agent-historical-person-form/agent-historical-person-form.component.spec.ts create mode 100644 frontend/src/app/data-entry/agent-form/agent-historical-person-form/agent-historical-person-form.component.ts create mode 100644 frontend/src/app/data-entry/agent-form/agent-historical-person-form/agent-historical-person-form.graphql create mode 100644 frontend/src/app/data-entry/shared/historical-person-select/historical-person-select.component.html create mode 100644 frontend/src/app/data-entry/shared/historical-person-select/historical-person-select.component.scss create mode 100644 frontend/src/app/data-entry/shared/historical-person-select/historical-person-select.component.spec.ts create mode 100644 frontend/src/app/data-entry/shared/historical-person-select/historical-person-select.component.ts diff --git a/backend/person/mutations/UpdateAgentMutation.py b/backend/person/mutations/UpdateAgentMutation.py index b2e36e21..090c653c 100644 --- a/backend/person/mutations/UpdateAgentMutation.py +++ b/backend/person/mutations/UpdateAgentMutation.py @@ -42,6 +42,7 @@ class UpdateAgentInput(InputObjectType): is_group = Boolean() gender = UpdateAgentGenderInput() location = UpdateAgentLocationInput() + describes = List(NonNull(String)) class UpdateAgentMutation(LettercraftMutation): diff --git a/backend/person/queries.py b/backend/person/queries.py index 91e933b4..d4d05deb 100644 --- a/backend/person/queries.py +++ b/backend/person/queries.py @@ -2,7 +2,8 @@ from django.db.models import QuerySet, Q from typing import Optional -from person.models import AgentDescription +from person.models import AgentDescription, HistoricalPerson +from person.types.HistoricalPersonType import HistoricalPersonType from person.types.AgentDescriptionType import AgentDescriptionType @@ -11,6 +12,7 @@ class PersonQueries(ObjectType): agent_descriptions = List( NonNull(AgentDescriptionType), required=True, episode_id=ID(), source_id=ID() ) + historical_persons = List(NonNull(HistoricalPersonType), required=True) @staticmethod def resolve_agent_description( @@ -39,3 +41,9 @@ def resolve_agent_descriptions( return AgentDescriptionType.get_queryset(AgentDescription.objects, info).filter( filters ) + + @staticmethod + def resolve_historical_persons( + parent: None, info: ResolveInfo + ) -> QuerySet[HistoricalPerson]: + return HistoricalPersonType.get_queryset(HistoricalPerson.objects, info) diff --git a/frontend/generated/graphql.ts b/frontend/generated/graphql.ts index 48e83720..4738713a 100644 --- a/frontend/generated/graphql.ts +++ b/frontend/generated/graphql.ts @@ -651,6 +651,7 @@ export type Query = { episodes: Array; giftDescription?: Maybe; giftDescriptions: Array; + historicalPersons: Array; letterCategories: Array; letterDescription?: Maybe; letterDescriptions: Array; @@ -930,6 +931,7 @@ export type UpdateAgentGenderInput = { }; export type UpdateAgentInput = { + describes?: InputMaybe>; description?: InputMaybe; gender?: InputMaybe; id: Scalars['ID']['input']; @@ -1105,6 +1107,18 @@ export type DataEntryDeleteEpisodeEntityLinkMutationVariables = Exact<{ export type DataEntryDeleteEpisodeEntityLinkMutation = { __typename?: 'Mutation', deleteEpisodeEntityLink?: { __typename?: 'DeleteEpisodeEntityLinkMutation', ok: boolean, errors: Array<{ __typename?: 'LettercraftErrorType', field: string, messages: Array }> } | null }; +export type DataEntryAgentHistoricalPersonQueryVariables = Exact<{ + id: Scalars['ID']['input']; +}>; + + +export type DataEntryAgentHistoricalPersonQuery = { __typename?: 'Query', agentDescription?: { __typename?: 'AgentDescriptionType', id: string, isGroup: boolean, describes: Array<{ __typename?: 'HistoricalPersonType', id: string }> } | null }; + +export type DataEntryHistoricalPersonsQueryVariables = Exact<{ [key: string]: never; }>; + + +export type DataEntryHistoricalPersonsQuery = { __typename?: 'Query', historicalPersons: Array<{ __typename?: 'HistoricalPersonType', id: string, name: string, dateOfBirth?: { __typename?: 'PersonDateOfBirthType', id: string, displayDate: string } | null, dateOfDeath?: { __typename?: 'PersonDateOfDeathType', id: string, displayDate: string } | null }> }; + export type DataEntryAgentIdentificationQueryVariables = Exact<{ id: Scalars['ID']['input']; }>; @@ -1555,6 +1569,55 @@ export const DataEntryDeleteEpisodeEntityLinkDocument = gql` export class DataEntryDeleteEpisodeEntityLinkGQL extends Apollo.Mutation { override document = DataEntryDeleteEpisodeEntityLinkDocument; + constructor(apollo: Apollo.Apollo) { + super(apollo); + } + } +export const DataEntryAgentHistoricalPersonDocument = gql` + query DataEntryAgentHistoricalPerson($id: ID!) { + agentDescription(id: $id) { + id + isGroup + describes { + id + } + } +} + `; + + @Injectable({ + providedIn: 'root' + }) + export class DataEntryAgentHistoricalPersonGQL extends Apollo.Query { + override document = DataEntryAgentHistoricalPersonDocument; + + constructor(apollo: Apollo.Apollo) { + super(apollo); + } + } +export const DataEntryHistoricalPersonsDocument = gql` + query DataEntryHistoricalPersons { + historicalPersons { + id + name + dateOfBirth { + id + displayDate + } + dateOfDeath { + id + displayDate + } + } +} + `; + + @Injectable({ + providedIn: 'root' + }) + export class DataEntryHistoricalPersonsGQL extends Apollo.Query { + override document = DataEntryHistoricalPersonsDocument; + constructor(apollo: Apollo.Apollo) { super(apollo); } diff --git a/frontend/generated/schema.graphql b/frontend/generated/schema.graphql index 60f80d6d..73b26514 100644 --- a/frontend/generated/schema.graphql +++ b/frontend/generated/schema.graphql @@ -540,6 +540,7 @@ type Query { episodes(sourceId: ID): [EpisodeType!]! giftDescription(id: ID!): GiftDescriptionType giftDescriptions(episodeId: ID, sourceId: ID): [GiftDescriptionType!]! + historicalPersons: [HistoricalPersonType!]! letterCategories: [LetterCategoryType!]! letterDescription(id: ID!): LetterDescriptionType letterDescriptions(episodeId: ID, sourceId: ID): [LetterDescriptionType!]! @@ -783,6 +784,7 @@ input UpdateAgentGenderInput { } input UpdateAgentInput { + describes: [String!] description: String gender: UpdateAgentGenderInput id: ID! diff --git a/frontend/src/app/data-entry/agent-form/agent-form.component.html b/frontend/src/app/data-entry/agent-form/agent-form.component.html index 561e8176..93086fd7 100644 --- a/frontend/src/app/data-entry/agent-form/agent-form.component.html +++ b/frontend/src/app/data-entry/agent-form/agent-form.component.html @@ -37,7 +37,7 @@

Episodes

Historical context

-

Coming soon!

+ diff --git a/frontend/src/app/data-entry/agent-form/agent-form.module.ts b/frontend/src/app/data-entry/agent-form/agent-form.module.ts index a1eb68fc..9cc1bdf7 100644 --- a/frontend/src/app/data-entry/agent-form/agent-form.module.ts +++ b/frontend/src/app/data-entry/agent-form/agent-form.module.ts @@ -6,6 +6,7 @@ import { AgentDescriptionFormComponent } from './agent-description-form/agent-de import { DataEntrySharedModule } from "../shared/data-entry-shared.module"; import { DeleteAgentComponent } from './delete-agent/delete-agent.component'; import { AgentEpisodesFormComponent } from './agent-episodes-form/agent-episodes-form.component'; +import { AgentHistoricalPersonFormComponent } from './agent-historical-person-form/agent-historical-person-form.component'; @NgModule({ @@ -15,6 +16,7 @@ import { AgentEpisodesFormComponent } from './agent-episodes-form/agent-episodes AgentDescriptionFormComponent, DeleteAgentComponent, AgentEpisodesFormComponent, + AgentHistoricalPersonFormComponent, ], imports: [ SharedModule, diff --git a/frontend/src/app/data-entry/agent-form/agent-historical-person-form/agent-historical-person-form.component.html b/frontend/src/app/data-entry/agent-form/agent-historical-person-form/agent-historical-person-form.component.html new file mode 100644 index 00000000..138ac6de --- /dev/null +++ b/frontend/src/app/data-entry/agent-form/agent-historical-person-form/agent-historical-person-form.component.html @@ -0,0 +1,17 @@ +
+ + {{ combined.isGroup ? "Historical persons" : "Historical person" }} + +

+ {{ + combined.isGroup + ? "Pick one or more historical persons that are described by this agent in this source." + : "Pick an historical person that is described by this agent in this source." + }} +

+ +
diff --git a/frontend/src/app/data-entry/agent-form/agent-historical-person-form/agent-historical-person-form.component.scss b/frontend/src/app/data-entry/agent-form/agent-historical-person-form/agent-historical-person-form.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/frontend/src/app/data-entry/agent-form/agent-historical-person-form/agent-historical-person-form.component.spec.ts b/frontend/src/app/data-entry/agent-form/agent-historical-person-form/agent-historical-person-form.component.spec.ts new file mode 100644 index 00000000..16acc3be --- /dev/null +++ b/frontend/src/app/data-entry/agent-form/agent-historical-person-form/agent-historical-person-form.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; + +import { AgentHistoricalPersonFormComponent } from "./agent-historical-person-form.component"; + +describe("AgentHistoricalPersonFormComponent", () => { + let component: AgentHistoricalPersonFormComponent; + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [AgentHistoricalPersonFormComponent], + }); + fixture = TestBed.createComponent(AgentHistoricalPersonFormComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/data-entry/agent-form/agent-historical-person-form/agent-historical-person-form.component.ts b/frontend/src/app/data-entry/agent-form/agent-historical-person-form/agent-historical-person-form.component.ts new file mode 100644 index 00000000..a05bd0b2 --- /dev/null +++ b/frontend/src/app/data-entry/agent-form/agent-historical-person-form/agent-historical-person-form.component.ts @@ -0,0 +1,178 @@ +import { Component, DestroyRef, OnDestroy, OnInit } from "@angular/core"; +import { FormService } from "../../shared/form.service"; +import { + DataEntryAgentHistoricalPersonGQL, + DataEntryHistoricalPersonsGQL, + DataEntryUpdateAgentGQL, + DataEntryUpdateAgentMutation, +} from "generated/graphql"; +import { + combineLatest, + debounceTime, + filter, + map, + Observable, + share, + switchMap, + withLatestFrom, +} from "rxjs"; +import { FormControl, FormGroup } from "@angular/forms"; +import { formStatusSubject } from "../../shared/utils"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { MultiselectOption } from "../../shared/multiselect/multiselect.component"; +import { MutationResult } from "apollo-angular"; +import { ToastService } from "@services/toast.service"; + +interface HistoricalPerson { + describes: string[]; +} + +type HistoricalPersonForm = { + [key in keyof HistoricalPerson]: FormControl; +}; + +@Component({ + selector: "lc-agent-historical-person-form", + templateUrl: "./agent-historical-person-form.component.html", + styleUrls: ["./agent-historical-person-form.component.scss"], +}) +export class AgentHistoricalPersonFormComponent implements OnInit, OnDestroy { + private id$ = this.formService.id$; + + private agent$ = this.id$.pipe( + switchMap((id) => this.agentQuery.watch({ id }).valueChanges), + map((result) => result.data.agentDescription), + share() + ); + + private allHistoricalPersons$ = this.historicalPersonsQuery + .fetch() + .pipe(map((result) => result.data.historicalPersons)); + + private historicalPersonOptions$: Observable = + this.allHistoricalPersons$.pipe( + map((persons) => + persons.map((person) => ({ + value: person.id, + label: `${person.name} (${ + person.dateOfBirth?.displayDate ?? " ?" + } – ${person.dateOfDeath?.displayDate ?? "? "})`, + })) + ) + ); + + private isGroup$ = this.agent$.pipe( + map((agent) => agent?.isGroup ?? false), + share() + ); + + public combined$ = combineLatest([ + this.historicalPersonOptions$, + this.isGroup$, + ]).pipe(map(([options, isGroup]) => ({ options, isGroup }))); + + public form = new FormGroup({ + describes: new FormControl([], { nonNullable: true }), + }); + + private formName = "historicalPersons"; + private status$ = formStatusSubject(); + + constructor( + private destroyRef: DestroyRef, + private formService: FormService, + private toastService: ToastService, + private agentQuery: DataEntryAgentHistoricalPersonGQL, + private historicalPersonsQuery: DataEntryHistoricalPersonsGQL, + private updateAgent: DataEntryUpdateAgentGQL + ) {} + + ngOnInit(): void { + this.formService.attachForm(this.formName, this.status$); + + this.agent$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((agent) => { + const historicalPersonIds = agent?.describes.map( + (person) => person.id + ); + this.form.patchValue({ describes: historicalPersonIds }); + }); + + this.form.statusChanges + .pipe( + filter((status) => status === "INVALID"), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(() => this.status$.next("invalid")); + + const validFormSubmission$ = this.agent$.pipe( + switchMap(() => + this.form.valueChanges.pipe( + map(() => this.form.getRawValue()), + filter(() => this.form.valid) + ) + ), + debounceTime(300), + share() + ); + + validFormSubmission$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => this.status$.next("loading")); + + validFormSubmission$ + .pipe( + withLatestFrom(this.id$), + switchMap(([agent, id]) => this.performMutation(agent, id)), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe((result) => this.onMutationResult(result)); + + // If the agent switches from being a group to a person, + // the list of selected persons is truncated to the first one. + this.isGroup$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((isGroup) => { + const currentDescribesValue = + this.form.controls.describes.value; + + if (isGroup || currentDescribesValue.length < 1) { + return; + } + + this.form.patchValue({ describes: [currentDescribesValue[0]] }); + }); + } + + ngOnDestroy(): void { + this.formService.detachForm(this.formName); + } + + private performMutation( + form: HistoricalPerson, + id: string + ): Observable> { + return this.updateAgent.mutate({ + input: { + id, + describes: form.describes, + }, + }); + } + + private onMutationResult( + result: MutationResult + ): void { + const errors = result.data?.updateAgent?.errors; + if (errors && errors.length > 0) { + this.status$.next("error"); + this.toastService.show({ + body: errors.map((error) => error.messages).join("\n"), + type: "danger", + header: "Update failed", + }); + } + this.status$.next("saved"); + } +} diff --git a/frontend/src/app/data-entry/agent-form/agent-historical-person-form/agent-historical-person-form.graphql b/frontend/src/app/data-entry/agent-form/agent-historical-person-form/agent-historical-person-form.graphql new file mode 100644 index 00000000..f84faea1 --- /dev/null +++ b/frontend/src/app/data-entry/agent-form/agent-historical-person-form/agent-historical-person-form.graphql @@ -0,0 +1,24 @@ +query DataEntryAgentHistoricalPerson($id: ID!) { + agentDescription(id: $id) { + id + isGroup + describes { + id + } + } +} + +query DataEntryHistoricalPersons { + historicalPersons { + id + name + dateOfBirth { + id + displayDate + } + dateOfDeath { + id + displayDate + } + } +} diff --git a/frontend/src/app/data-entry/episode-form/episode-source-text-form/episode-source-text-form.component.ts b/frontend/src/app/data-entry/episode-form/episode-source-text-form/episode-source-text-form.component.ts index 26ffa043..414ad78f 100644 --- a/frontend/src/app/data-entry/episode-form/episode-source-text-form/episode-source-text-form.component.ts +++ b/frontend/src/app/data-entry/episode-form/episode-source-text-form/episode-source-text-form.component.ts @@ -8,17 +8,14 @@ import { DataEntryUpdateEpisodeMutation, } from "generated/graphql"; import { - BehaviorSubject, debounceTime, filter, map, share, shareReplay, switchMap, - tap, withLatestFrom, } from "rxjs"; -import { FormStatus } from "../../shared/types"; import { FormService } from "../../shared/form.service"; import { formStatusSubject } from "../../shared/utils"; import { MutationResult } from "apollo-angular"; diff --git a/frontend/src/app/data-entry/shared/data-entry-shared.module.ts b/frontend/src/app/data-entry/shared/data-entry-shared.module.ts index b25d64e0..5cd6fcb1 100644 --- a/frontend/src/app/data-entry/shared/data-entry-shared.module.ts +++ b/frontend/src/app/data-entry/shared/data-entry-shared.module.ts @@ -5,8 +5,9 @@ import { MultiselectComponent } from "./multiselect/multiselect.component"; import { LabelSelectComponent } from "./label-select/label-select.component"; import { EntityDescriptionLeadComponent } from "./entity-description-lead/entity-description-lead.component"; import { DeleteEntityButtonComponent } from "./delete-entity-button/delete-entity-button.component"; -import { FormStatusComponent } from './form-status/form-status.component'; -import { EpisodeLinkFormComponent } from './episode-link-form/episode-link-form.component'; +import { FormStatusComponent } from "./form-status/form-status.component"; +import { EpisodeLinkFormComponent } from "./episode-link-form/episode-link-form.component"; +import { HistoricalPersonSelectComponent } from "./historical-person-select/historical-person-select.component"; @NgModule({ declarations: [ @@ -17,6 +18,7 @@ import { EpisodeLinkFormComponent } from './episode-link-form/episode-link-form. DeleteEntityButtonComponent, FormStatusComponent, EpisodeLinkFormComponent, + HistoricalPersonSelectComponent, ], imports: [SharedModule], exports: [ @@ -27,6 +29,7 @@ import { EpisodeLinkFormComponent } from './episode-link-form/episode-link-form. DeleteEntityButtonComponent, FormStatusComponent, EpisodeLinkFormComponent, + HistoricalPersonSelectComponent, ], }) export class DataEntrySharedModule {} diff --git a/frontend/src/app/data-entry/shared/historical-person-select/historical-person-select.component.html b/frontend/src/app/data-entry/shared/historical-person-select/historical-person-select.component.html new file mode 100644 index 00000000..d70a28d1 --- /dev/null +++ b/frontend/src/app/data-entry/shared/historical-person-select/historical-person-select.component.html @@ -0,0 +1,46 @@ +

Currently selected:

+ +
    +
  • + {{ person.label }} + +
  • +
+
+ +
+

+ {{ selectedPersons()[0].label }} + +

+
+
+ +
+ + diff --git a/frontend/src/app/data-entry/shared/historical-person-select/historical-person-select.component.scss b/frontend/src/app/data-entry/shared/historical-person-select/historical-person-select.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/frontend/src/app/data-entry/shared/historical-person-select/historical-person-select.component.spec.ts b/frontend/src/app/data-entry/shared/historical-person-select/historical-person-select.component.spec.ts new file mode 100644 index 00000000..b4fa010d --- /dev/null +++ b/frontend/src/app/data-entry/shared/historical-person-select/historical-person-select.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; + +import { HistoricalPersonSelectComponent } from "./historical-person-select.component"; + +describe("HistoricalPersonSelectComponent", () => { + let component: HistoricalPersonSelectComponent; + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [HistoricalPersonSelectComponent], + }); + fixture = TestBed.createComponent(HistoricalPersonSelectComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/data-entry/shared/historical-person-select/historical-person-select.component.ts b/frontend/src/app/data-entry/shared/historical-person-select/historical-person-select.component.ts new file mode 100644 index 00000000..28d7f8fd --- /dev/null +++ b/frontend/src/app/data-entry/shared/historical-person-select/historical-person-select.component.ts @@ -0,0 +1,89 @@ +import { + Component, + computed, + DestroyRef, + forwardRef, + Input, + OnInit, +} from "@angular/core"; +import { + ControlValueAccessor, + FormControl, + NG_VALUE_ACCESSOR, +} from "@angular/forms"; +import { actionIcons } from "@shared/icons"; +import { MultiselectOption } from "../multiselect/multiselect.component"; +import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop"; + +@Component({ + selector: "lc-historical-person-select", + templateUrl: "./historical-person-select.component.html", + styleUrls: ["./historical-person-select.component.scss"], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => HistoricalPersonSelectComponent), + multi: true, + }, + ], +}) +export class HistoricalPersonSelectComponent + implements ControlValueAccessor, OnInit +{ + @Input() multiple = false; + @Input({ required: true }) options: MultiselectOption[] = []; + + public control = new FormControl([], { nonNullable: true }); + public actionIcons = actionIcons; + + private onChange: ((value: string[]) => void) | null = null; + private onTouched: (() => void) | null = null; + + public formValue = toSignal(this.control.valueChanges); + public selectedPersons = computed(() => { + const selectedIds = this.formValue(); + return this.options.filter((item) => selectedIds?.includes(item.value)); + }); + + constructor(private destroyRef: DestroyRef) {} + + ngOnInit(): void { + this.control.valueChanges + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((value) => { + if (this.onTouched) { + this.onTouched(); + } + if (this.onChange) { + this.onChange(value); + } + }); + } + + public removePerson(id: string): void { + const newValue = this.control.value.filter((item) => item !== id); + this.control.setValue(newValue); + } + + public writeValue(obj: string[]): void { + const newValue = + obj.length > 0 && this.multiple === false ? [obj[0]] : obj; + this.control.setValue(newValue); + } + + public registerOnChange(fn: (value: string[] | null) => void): void { + this.onChange = fn; + } + + public registerOnTouched(fn: () => void): void { + this.onTouched = fn; + } + + public setDisabledState?(isDisabled: boolean): void { + if (isDisabled) { + this.control.disable(); + } else { + this.control.enable(); + } + } +} From 49f325328f8f2ccd2d1f0ede52ac234050fd0b1b Mon Sep 17 00:00:00 2001 From: Xander Vertegaal Date: Sat, 7 Dec 2024 17:51:15 +0100 Subject: [PATCH 2/9] Add form validator --- frontend/generated/graphql.ts | 5 +++- .../agent-historical-person-form.component.ts | 18 ++---------- .../agent-identification-form.component.html | 3 +- .../agent-identification-form.component.ts | 23 +++++++++++---- .../agents-identification.graphql | 3 ++ .../agent-form/validators/isGroupValidator.ts | 28 +++++++++++++++++++ 6 files changed, 57 insertions(+), 23 deletions(-) create mode 100644 frontend/src/app/data-entry/agent-form/validators/isGroupValidator.ts diff --git a/frontend/generated/graphql.ts b/frontend/generated/graphql.ts index 4738713a..c8776994 100644 --- a/frontend/generated/graphql.ts +++ b/frontend/generated/graphql.ts @@ -1124,7 +1124,7 @@ export type DataEntryAgentIdentificationQueryVariables = Exact<{ }>; -export type DataEntryAgentIdentificationQuery = { __typename?: 'Query', agentDescription?: { __typename?: 'AgentDescriptionType', id: string, name: string, description: string, isGroup: boolean } | null }; +export type DataEntryAgentIdentificationQuery = { __typename?: 'Query', agentDescription?: { __typename?: 'AgentDescriptionType', id: string, name: string, description: string, isGroup: boolean, describes: Array<{ __typename?: 'HistoricalPersonType', id: string }> } | null }; export type DataEntryAgentQueryVariables = Exact<{ id: Scalars['ID']['input']; @@ -1629,6 +1629,9 @@ export const DataEntryAgentIdentificationDocument = gql` name description isGroup + describes { + id + } } } `; diff --git a/frontend/src/app/data-entry/agent-form/agent-historical-person-form/agent-historical-person-form.component.ts b/frontend/src/app/data-entry/agent-form/agent-historical-person-form/agent-historical-person-form.component.ts index a05bd0b2..1ec06f36 100644 --- a/frontend/src/app/data-entry/agent-form/agent-historical-person-form/agent-historical-person-form.component.ts +++ b/frontend/src/app/data-entry/agent-form/agent-historical-person-form/agent-historical-person-form.component.ts @@ -128,21 +128,6 @@ export class AgentHistoricalPersonFormComponent implements OnInit, OnDestroy { takeUntilDestroyed(this.destroyRef) ) .subscribe((result) => this.onMutationResult(result)); - - // If the agent switches from being a group to a person, - // the list of selected persons is truncated to the first one. - this.isGroup$ - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((isGroup) => { - const currentDescribesValue = - this.form.controls.describes.value; - - if (isGroup || currentDescribesValue.length < 1) { - return; - } - - this.form.patchValue({ describes: [currentDescribesValue[0]] }); - }); } ngOnDestroy(): void { @@ -158,6 +143,9 @@ export class AgentHistoricalPersonFormComponent implements OnInit, OnDestroy { id, describes: form.describes, }, + }, { + // Refetch the form with the isGroup control to update validation. + refetchQueries: ["DataEntryAgentIdentification"], }); } diff --git a/frontend/src/app/data-entry/agent-form/agent-identification-form/agent-identification-form.component.html b/frontend/src/app/data-entry/agent-form/agent-identification-form/agent-identification-form.component.html index ee7f0a18..93c5eb14 100644 --- a/frontend/src/app/data-entry/agent-form/agent-identification-form/agent-identification-form.component.html +++ b/frontend/src/app/data-entry/agent-form/agent-identification-form/agent-identification-form.component.html @@ -36,11 +36,12 @@ - +

A single-person agent cannot be linked to more than one historical person.

diff --git a/frontend/src/app/data-entry/agent-form/agent-identification-form/agent-identification-form.component.ts b/frontend/src/app/data-entry/agent-form/agent-identification-form/agent-identification-form.component.ts index ff239466..b81bc738 100644 --- a/frontend/src/app/data-entry/agent-form/agent-identification-form/agent-identification-form.component.ts +++ b/frontend/src/app/data-entry/agent-form/agent-identification-form/agent-identification-form.component.ts @@ -18,6 +18,7 @@ import _ from 'underscore'; import { FormService } from '../../shared/form.service'; import { FormStatus } from '../../shared/types'; import { listWithQuotes, nameExamples } from '../../shared/utils'; +import { isGroupValidator } from '../validators/isGroupValidator'; interface FormData { name: string; @@ -42,6 +43,9 @@ export class AgentIdentificationFormComponent implements OnDestroy { updateOn: 'blur', }), isGroup: new FormControl(false, { nonNullable: true }), + numberOfHistoricalPersons: new FormControl(0, { nonNullable: true }), + }, { + validators: [isGroupValidator()] }); status$ = new BehaviorSubject('idle'); @@ -61,7 +65,7 @@ export class AgentIdentificationFormComponent implements OnDestroy { this.id$.pipe( switchMap(id => this.agentQuery.watch({ id }).valueChanges), - map(result => result.data), + map(result => result.data.agentDescription), takeUntilDestroyed(), ).subscribe(this.updateFormData.bind(this)); @@ -88,20 +92,27 @@ export class AgentIdentificationFormComponent implements OnDestroy { this.formService.detachForm(this.formName); } - updateFormData(data: DataEntryAgentIdentificationQuery) { + updateFormData(agentDescription: DataEntryAgentIdentificationQuery['agentDescription']) { this.form.setValue({ - name: data.agentDescription?.name || '', - description: data.agentDescription?.description || '', - isGroup: data.agentDescription?.isGroup || false, + name: agentDescription?.name || '', + description: agentDescription?.description || '', + isGroup: agentDescription?.isGroup || false, + numberOfHistoricalPersons: agentDescription?.describes.length || 0, }); } private isValid(): boolean { + this.form.updateValueAndValidity(); return this.form.valid; } private toMutationInput([data, id]: [Partial, string]): UpdateAgentInput { - return { id, ...data }; + return { + id, + name: data.name, + description: data.description, + isGroup: data.isGroup, + }; } private makeMutation(input: UpdateAgentInput): diff --git a/frontend/src/app/data-entry/agent-form/agent-identification-form/agents-identification.graphql b/frontend/src/app/data-entry/agent-form/agent-identification-form/agents-identification.graphql index fd47a0e3..14d42d51 100644 --- a/frontend/src/app/data-entry/agent-form/agent-identification-form/agents-identification.graphql +++ b/frontend/src/app/data-entry/agent-form/agent-identification-form/agents-identification.graphql @@ -4,5 +4,8 @@ query DataEntryAgentIdentification($id: ID!) { name description isGroup + describes { + id + } } } diff --git a/frontend/src/app/data-entry/agent-form/validators/isGroupValidator.ts b/frontend/src/app/data-entry/agent-form/validators/isGroupValidator.ts new file mode 100644 index 00000000..ae5c41c5 --- /dev/null +++ b/frontend/src/app/data-entry/agent-form/validators/isGroupValidator.ts @@ -0,0 +1,28 @@ +import { + ValidatorFn, + AbstractControl, + ValidationErrors, + FormGroup, +} from "@angular/forms"; + +/** + * Validator to verify that if the form represents a single person (isGroup is false), + * the number of historical persons linked to this form should not be more than one. + */ +export function isGroupValidator(): ValidatorFn { + return (control: AbstractControl): ValidationErrors | null => { + const form = control as FormGroup; + const isGroup = form.get("isGroup")?.value; + const numberOfHistoricalPersons = form.get( + "numberOfHistoricalPersons" + )?.value; + + if (isGroup === false && numberOfHistoricalPersons > 1) { + return { + numberOfHistoricalPersons: + "Single-person agents cannot be linked to multiple historical persons.", + }; + } + return null; + }; +} From 2d15266a275b2a9cf6efe4ecfef8be4c99a1b1a1 Mon Sep 17 00:00:00 2001 From: Xander Vertegaal Date: Sat, 7 Dec 2024 18:56:58 +0100 Subject: [PATCH 3/9] Fix tests --- .../agent-historical-person-form.component.spec.ts | 4 ++++ .../historical-person-select.component.spec.ts | 2 ++ 2 files changed, 6 insertions(+) diff --git a/frontend/src/app/data-entry/agent-form/agent-historical-person-form/agent-historical-person-form.component.spec.ts b/frontend/src/app/data-entry/agent-form/agent-historical-person-form/agent-historical-person-form.component.spec.ts index 16acc3be..6edc2c43 100644 --- a/frontend/src/app/data-entry/agent-form/agent-historical-person-form/agent-historical-person-form.component.spec.ts +++ b/frontend/src/app/data-entry/agent-form/agent-historical-person-form/agent-historical-person-form.component.spec.ts @@ -1,6 +1,8 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { AgentHistoricalPersonFormComponent } from "./agent-historical-person-form.component"; +import { FormService } from "../../shared/form.service"; +import { SharedTestingModule } from "@shared/shared-testing.module"; describe("AgentHistoricalPersonFormComponent", () => { let component: AgentHistoricalPersonFormComponent; @@ -9,6 +11,8 @@ describe("AgentHistoricalPersonFormComponent", () => { beforeEach(() => { TestBed.configureTestingModule({ declarations: [AgentHistoricalPersonFormComponent], + imports: [SharedTestingModule], + providers: [FormService], }); fixture = TestBed.createComponent(AgentHistoricalPersonFormComponent); component = fixture.componentInstance; diff --git a/frontend/src/app/data-entry/shared/historical-person-select/historical-person-select.component.spec.ts b/frontend/src/app/data-entry/shared/historical-person-select/historical-person-select.component.spec.ts index b4fa010d..58fc7ffd 100644 --- a/frontend/src/app/data-entry/shared/historical-person-select/historical-person-select.component.spec.ts +++ b/frontend/src/app/data-entry/shared/historical-person-select/historical-person-select.component.spec.ts @@ -1,6 +1,7 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { HistoricalPersonSelectComponent } from "./historical-person-select.component"; +import { SharedTestingModule } from "@shared/shared-testing.module"; describe("HistoricalPersonSelectComponent", () => { let component: HistoricalPersonSelectComponent; @@ -9,6 +10,7 @@ describe("HistoricalPersonSelectComponent", () => { beforeEach(() => { TestBed.configureTestingModule({ declarations: [HistoricalPersonSelectComponent], + imports: [SharedTestingModule], }); fixture = TestBed.createComponent(HistoricalPersonSelectComponent); component = fixture.componentInstance; From 9fcc99c6f24a73bcae977b284a0c7a0239c3e029 Mon Sep 17 00:00:00 2001 From: Xander Vertegaal Date: Mon, 9 Dec 2024 20:32:26 +0100 Subject: [PATCH 4/9] Fix spurious agent duplication bug --- backend/event/types/EpisodeType.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/backend/event/types/EpisodeType.py b/backend/event/types/EpisodeType.py index ecfd933f..03078fae 100644 --- a/backend/event/types/EpisodeType.py +++ b/backend/event/types/EpisodeType.py @@ -5,10 +5,14 @@ from event.models import Episode, EpisodeCategory from event.types.EpisodeCategoryType import EpisodeCategoryType +from person.models import AgentDescription class EpisodeType(EntityDescriptionType, DjangoObjectType): categories = List(NonNull(EpisodeCategoryType), required=True) + agents = List( + NonNull("person.types.AgentDescriptionType.AgentDescriptionType"), required=True + ) class Meta: model = Episode @@ -17,7 +21,6 @@ class Meta: "summary", "categories", "designators", - "agents", "gifts", "letters", "spaces", @@ -32,6 +35,14 @@ def get_queryset( ) -> QuerySet[Episode]: return queryset.all() + @staticmethod + def resolve_agents( + parent: Episode, info: ResolveInfo + ) -> QuerySet[AgentDescription]: + # Without distinct(), this returns one agent for every HistoricalPerson linked + # to that agent, for some reason. + return parent.agents.distinct() + @staticmethod def resolve_categories( parent: Episode, info: ResolveInfo From c5aff9b1d14afbbe3bea8393b6368414c420c34f Mon Sep 17 00:00:00 2001 From: Xander Vertegaal Date: Wed, 18 Dec 2024 09:34:08 +0100 Subject: [PATCH 5/9] Rename validator file --- .../agent-form/validators/{isGroupValidator.ts => validators.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename frontend/src/app/data-entry/agent-form/validators/{isGroupValidator.ts => validators.ts} (100%) diff --git a/frontend/src/app/data-entry/agent-form/validators/isGroupValidator.ts b/frontend/src/app/data-entry/agent-form/validators/validators.ts similarity index 100% rename from frontend/src/app/data-entry/agent-form/validators/isGroupValidator.ts rename to frontend/src/app/data-entry/agent-form/validators/validators.ts From 0c15a5e73ea2acb3cef184b1647fd009bfa5ea54 Mon Sep 17 00:00:00 2001 From: Xander Vertegaal Date: Wed, 18 Dec 2024 09:35:28 +0100 Subject: [PATCH 6/9] Improve template data variable names --- ...gent-historical-person-form.component.html | 29 ++++++++++--------- .../agent-historical-person-form.component.ts | 9 ++---- 2 files changed, 18 insertions(+), 20 deletions(-) diff --git a/frontend/src/app/data-entry/agent-form/agent-historical-person-form/agent-historical-person-form.component.html b/frontend/src/app/data-entry/agent-form/agent-historical-person-form/agent-historical-person-form.component.html index 138ac6de..bc08259e 100644 --- a/frontend/src/app/data-entry/agent-form/agent-historical-person-form/agent-historical-person-form.component.html +++ b/frontend/src/app/data-entry/agent-form/agent-historical-person-form/agent-historical-person-form.component.html @@ -1,17 +1,20 @@ -
- - {{ combined.isGroup ? "Historical persons" : "Historical person" }} - -

- {{ - combined.isGroup - ? "Pick one or more historical persons that are described by this agent in this source." - : "Pick an historical person that is described by this agent in this source." - }} -

+
+ + + {{ isGroup ? "Historical persons" : "Historical person" }} + +

+ {{ + isGroup + ? "Pick one or more historical persons that are represented by this agent in this source." + : "Pick an historical person that is represented by this agent in this source." + }} +

+
diff --git a/frontend/src/app/data-entry/agent-form/agent-historical-person-form/agent-historical-person-form.component.ts b/frontend/src/app/data-entry/agent-form/agent-historical-person-form/agent-historical-person-form.component.ts index 1ec06f36..4196ec8a 100644 --- a/frontend/src/app/data-entry/agent-form/agent-historical-person-form/agent-historical-person-form.component.ts +++ b/frontend/src/app/data-entry/agent-form/agent-historical-person-form/agent-historical-person-form.component.ts @@ -49,7 +49,7 @@ export class AgentHistoricalPersonFormComponent implements OnInit, OnDestroy { .fetch() .pipe(map((result) => result.data.historicalPersons)); - private historicalPersonOptions$: Observable = + public historicalPersonOptions$: Observable = this.allHistoricalPersons$.pipe( map((persons) => persons.map((person) => ({ @@ -61,16 +61,11 @@ export class AgentHistoricalPersonFormComponent implements OnInit, OnDestroy { ) ); - private isGroup$ = this.agent$.pipe( + public isGroup$ = this.agent$.pipe( map((agent) => agent?.isGroup ?? false), share() ); - public combined$ = combineLatest([ - this.historicalPersonOptions$, - this.isGroup$, - ]).pipe(map(([options, isGroup]) => ({ options, isGroup }))); - public form = new FormGroup({ describes: new FormControl([], { nonNullable: true }), }); From 049a924ba6098be6b8abb487c6e7d9a2fee93615 Mon Sep 17 00:00:00 2001 From: Xander Vertegaal Date: Wed, 18 Dec 2024 09:36:07 +0100 Subject: [PATCH 7/9] Don't show dates for historical person options --- .../agent-historical-person-form.component.ts | 8 +++----- .../agent-historical-person-form.graphql | 8 -------- .../agent-identification-form.component.ts | 2 +- 3 files changed, 4 insertions(+), 14 deletions(-) diff --git a/frontend/src/app/data-entry/agent-form/agent-historical-person-form/agent-historical-person-form.component.ts b/frontend/src/app/data-entry/agent-form/agent-historical-person-form/agent-historical-person-form.component.ts index 4196ec8a..d2cc83aa 100644 --- a/frontend/src/app/data-entry/agent-form/agent-historical-person-form/agent-historical-person-form.component.ts +++ b/frontend/src/app/data-entry/agent-form/agent-historical-person-form/agent-historical-person-form.component.ts @@ -52,11 +52,9 @@ export class AgentHistoricalPersonFormComponent implements OnInit, OnDestroy { public historicalPersonOptions$: Observable = this.allHistoricalPersons$.pipe( map((persons) => - persons.map((person) => ({ - value: person.id, - label: `${person.name} (${ - person.dateOfBirth?.displayDate ?? " ?" - } – ${person.dateOfDeath?.displayDate ?? "? "})`, + persons.map(({ id, name }) => ({ + value: id, + label: name, })) ) ); diff --git a/frontend/src/app/data-entry/agent-form/agent-historical-person-form/agent-historical-person-form.graphql b/frontend/src/app/data-entry/agent-form/agent-historical-person-form/agent-historical-person-form.graphql index f84faea1..e16f3cc8 100644 --- a/frontend/src/app/data-entry/agent-form/agent-historical-person-form/agent-historical-person-form.graphql +++ b/frontend/src/app/data-entry/agent-form/agent-historical-person-form/agent-historical-person-form.graphql @@ -12,13 +12,5 @@ query DataEntryHistoricalPersons { historicalPersons { id name - dateOfBirth { - id - displayDate - } - dateOfDeath { - id - displayDate - } } } diff --git a/frontend/src/app/data-entry/agent-form/agent-identification-form/agent-identification-form.component.ts b/frontend/src/app/data-entry/agent-form/agent-identification-form/agent-identification-form.component.ts index b81bc738..c3a009a6 100644 --- a/frontend/src/app/data-entry/agent-form/agent-identification-form/agent-identification-form.component.ts +++ b/frontend/src/app/data-entry/agent-form/agent-identification-form/agent-identification-form.component.ts @@ -18,7 +18,7 @@ import _ from 'underscore'; import { FormService } from '../../shared/form.service'; import { FormStatus } from '../../shared/types'; import { listWithQuotes, nameExamples } from '../../shared/utils'; -import { isGroupValidator } from '../validators/isGroupValidator'; +import { isGroupValidator } from '../validators/validators'; interface FormData { name: string; From 8a3f496145a7d976394e05b94558287f3b4ac529 Mon Sep 17 00:00:00 2001 From: Xander Vertegaal Date: Wed, 18 Dec 2024 09:36:26 +0100 Subject: [PATCH 8/9] Show correct validation styling --- .../agent-identification-form.component.html | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/data-entry/agent-form/agent-identification-form/agent-identification-form.component.html b/frontend/src/app/data-entry/agent-form/agent-identification-form/agent-identification-form.component.html index 93c5eb14..321053fc 100644 --- a/frontend/src/app/data-entry/agent-form/agent-identification-form/agent-identification-form.component.html +++ b/frontend/src/app/data-entry/agent-form/agent-identification-form/agent-identification-form.component.html @@ -36,8 +36,12 @@ - From 7c17100be84da3c44c2c94cbb6798fa6b8227d4f Mon Sep 17 00:00:00 2001 From: Xander Vertegaal Date: Wed, 18 Dec 2024 09:36:41 +0100 Subject: [PATCH 9/9] Generated files --- frontend/generated/graphql.ts | 11 +---------- frontend/generated/schema.graphql | 1 - 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/frontend/generated/graphql.ts b/frontend/generated/graphql.ts index c8776994..c9f5219b 100644 --- a/frontend/generated/graphql.ts +++ b/frontend/generated/graphql.ts @@ -262,7 +262,6 @@ export type EpisodeSpaceType = EpisodeEntityLink & { export type EpisodeType = EntityDescription & { __typename?: 'EpisodeType'; - /** agents involved in this episode */ agents: Array; /** The book in the source */ book: Scalars['String']['output']; @@ -1117,7 +1116,7 @@ export type DataEntryAgentHistoricalPersonQuery = { __typename?: 'Query', agentD export type DataEntryHistoricalPersonsQueryVariables = Exact<{ [key: string]: never; }>; -export type DataEntryHistoricalPersonsQuery = { __typename?: 'Query', historicalPersons: Array<{ __typename?: 'HistoricalPersonType', id: string, name: string, dateOfBirth?: { __typename?: 'PersonDateOfBirthType', id: string, displayDate: string } | null, dateOfDeath?: { __typename?: 'PersonDateOfDeathType', id: string, displayDate: string } | null }> }; +export type DataEntryHistoricalPersonsQuery = { __typename?: 'Query', historicalPersons: Array<{ __typename?: 'HistoricalPersonType', id: string, name: string }> }; export type DataEntryAgentIdentificationQueryVariables = Exact<{ id: Scalars['ID']['input']; @@ -1600,14 +1599,6 @@ export const DataEntryHistoricalPersonsDocument = gql` historicalPersons { id name - dateOfBirth { - id - displayDate - } - dateOfDeath { - id - displayDate - } } } `; diff --git a/frontend/generated/schema.graphql b/frontend/generated/schema.graphql index 73b26514..c436e9d3 100644 --- a/frontend/generated/schema.graphql +++ b/frontend/generated/schema.graphql @@ -228,7 +228,6 @@ type EpisodeSpaceType implements EpisodeEntityLink { } type EpisodeType implements EntityDescription { - """agents involved in this episode""" agents: [AgentDescriptionType!]! """The book in the source"""